@yannelli/paseo-linear-plugin 0.0.0-stage → 0.3.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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +13 -0
  3. package/OVERVIEW.md +33 -0
  4. package/README.md +207 -2
  5. package/client/agent-options.ts +200 -0
  6. package/client/breadcrumbs.tsx +110 -0
  7. package/client/browser.tsx +275 -0
  8. package/client/compat.ts +107 -0
  9. package/client/connect.tsx +136 -0
  10. package/client/create-issue.tsx +291 -0
  11. package/client/glyphs.tsx +117 -0
  12. package/client/issue-detail.tsx +366 -0
  13. package/client/issue-list.tsx +373 -0
  14. package/client/issue-row.tsx +152 -0
  15. package/client/issue-sections.tsx +325 -0
  16. package/client/issue-tree.ts +132 -0
  17. package/client/key-scope.tsx +23 -0
  18. package/client/launch-choices.ts +125 -0
  19. package/client/launch-fields.tsx +152 -0
  20. package/client/launch-plan.ts +227 -0
  21. package/client/launch.tsx +484 -0
  22. package/client/markdown.tsx +240 -0
  23. package/client/model-browser.tsx +296 -0
  24. package/client/pickers.tsx +238 -0
  25. package/client/provider-icon.tsx +78 -0
  26. package/client/queries.ts +211 -0
  27. package/client/settings-connection.tsx +109 -0
  28. package/client/settings-fields.tsx +173 -0
  29. package/client/settings-keys.tsx +117 -0
  30. package/client/settings-projects.tsx +281 -0
  31. package/client/settings-prompts.tsx +198 -0
  32. package/client/store.ts +71 -0
  33. package/client/timeline-card.tsx +63 -0
  34. package/client/ui.tsx +352 -0
  35. package/index.client.tsx +111 -0
  36. package/index.server.ts +12 -0
  37. package/package.json +51 -4
  38. package/paseo-plugin.json +7 -0
  39. package/server/cache.ts +68 -0
  40. package/server/credentials.ts +101 -0
  41. package/server/graphql.ts +68 -0
  42. package/server/handlers.ts +172 -0
  43. package/server/linear.ts +130 -0
  44. package/server/queries.ts +280 -0
  45. package/shared/issues.ts +31 -0
  46. package/shared/linear.ts +268 -0
  47. package/shared/markdown.ts +416 -0
  48. package/shared/prompts.ts +150 -0
  49. package/shared/settings.ts +68 -0
@@ -0,0 +1,373 @@
1
+ import { Icon, TextInput } from "@getpaseo/plugin/client/react-native";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { FlatList, type ListRenderItemInfo, ScrollView, Text, View } from "react-native";
4
+ import type { AssigneeFilter, IssueSort, LinearTeam, StatusFilter } from "../shared/linear";
5
+ import { IssueRow, StateHeader } from "./issue-row";
6
+ import { buildIssueRows, type IssueListRow, toggleCollapsed } from "./issue-tree";
7
+ import { PickerModal } from "./pickers";
8
+ import { useIssues } from "./queries";
9
+ import type { BrowserState } from "./store";
10
+ import { Button, EmptyState, errorMessage, Segmented, type Theme } from "./ui";
11
+
12
+ const ASSIGNEE_OPTIONS: readonly { value: AssigneeFilter; label: string }[] = [
13
+ { value: "me", label: "Mine" },
14
+ { value: "anyone", label: "Everyone" },
15
+ { value: "unassigned", label: "Unassigned" },
16
+ ];
17
+ const STATUS_OPTIONS: readonly { value: StatusFilter; label: string }[] = [
18
+ { value: "active", label: "Active" },
19
+ { value: "backlog", label: "Backlog" },
20
+ { value: "done", label: "Done" },
21
+ { value: "all", label: "All" },
22
+ ];
23
+ export const SORT_OPTIONS: readonly { value: IssueSort; label: string }[] = [
24
+ { value: "updated", label: "Last updated" },
25
+ { value: "created", label: "Newest" },
26
+ { value: "priority", label: "Priority" },
27
+ { value: "due", label: "Due date" },
28
+ { value: "title", label: "Title" },
29
+ ];
30
+ const ALL_TEAMS = "__all__";
31
+ const ROOT_STYLE = { flex: 1, minHeight: 0 } as const;
32
+ const FILTER_SCROLL_STYLE = { flexGrow: 0 } as const;
33
+ const ROW_STYLE = { flexDirection: "row", gap: 8, alignItems: "center" } as const;
34
+
35
+ type Update = (patch: Partial<BrowserState>) => void;
36
+ type Picker = "team" | "sort" | "key";
37
+ const KEY_DEFAULT = "";
38
+
39
+ function rowKey(row: IssueListRow): string {
40
+ return row.key;
41
+ }
42
+
43
+ export interface KeyOption {
44
+ value: string;
45
+ label: string;
46
+ }
47
+
48
+ export function IssueList(props: {
49
+ theme: Theme;
50
+ compact: boolean;
51
+ teams: readonly LinearTeam[];
52
+ state: BrowserState;
53
+ update: Update;
54
+ /** Linear keys to switch between. Empty hides the key picker. */
55
+ keyOptions: readonly KeyOption[];
56
+ }) {
57
+ const { theme, state, update, keyOptions } = props;
58
+ const [picker, setPicker] = useState<Picker | null>(null);
59
+ const team = props.teams.find((entry) => entry.id === state.teamId) ?? null;
60
+ const keyValue = state.keyProjectId ?? KEY_DEFAULT;
61
+ const keyLabel = keyOptions.find((option) => option.value === keyValue)?.label ?? "Default key";
62
+ const sortLabel = SORT_OPTIONS.find((option) => option.value === state.sort)?.label ?? "Sort";
63
+ const picked = useMemo(() => {
64
+ if (picker === "sort") {
65
+ return { title: "Sort by", icon: "ArrowUpDown", options: SORT_OPTIONS, value: state.sort };
66
+ }
67
+ if (picker === "key") {
68
+ return { title: "Linear key", icon: "KeyRound", options: keyOptions, value: keyValue };
69
+ }
70
+ const options = [
71
+ { value: ALL_TEAMS, label: "All teams" },
72
+ ...props.teams.map((entry) => ({ value: entry.id, label: entry.name, detail: entry.key })),
73
+ ];
74
+ return { title: "Team", icon: "Users", options, value: state.teamId ?? ALL_TEAMS };
75
+ }, [picker, props.teams, keyOptions, keyValue, state.sort, state.teamId]);
76
+ const open = useCallback((kind: Picker) => setPicker(kind), []);
77
+ const close = useCallback(() => setPicker(null), []);
78
+ const select = useCallback(
79
+ (value: string) => {
80
+ setPicker(null);
81
+ if (picker === "sort") update({ sort: value as IssueSort });
82
+ else if (picker === "key") {
83
+ update({
84
+ keyProjectId: value === KEY_DEFAULT ? null : value,
85
+ issueId: null,
86
+ teamId: null,
87
+ launch: null,
88
+ });
89
+ } else update({ teamId: value === ALL_TEAMS ? null : value });
90
+ },
91
+ [picker, update],
92
+ );
93
+ return (
94
+ <View style={ROOT_STYLE}>
95
+ <IssueFilters
96
+ theme={theme}
97
+ compact={props.compact}
98
+ state={state}
99
+ teamName={team ? team.name : "All teams"}
100
+ sortLabel={sortLabel}
101
+ keyLabel={keyOptions.length > 0 ? keyLabel : null}
102
+ update={update}
103
+ onOpen={open}
104
+ />
105
+ <IssueResults theme={theme} compact={props.compact} state={state} update={update} />
106
+ <PickerModal
107
+ theme={theme}
108
+ title={picked.title}
109
+ icon={picked.icon}
110
+ open={picker !== null}
111
+ options={picked.options}
112
+ value={picked.value}
113
+ searchable={picker === "team" && props.teams.length > 8}
114
+ onClose={close}
115
+ onSelect={select}
116
+ />
117
+ </View>
118
+ );
119
+ }
120
+
121
+ function IssueFilters(props: {
122
+ theme: Theme;
123
+ compact: boolean;
124
+ state: BrowserState;
125
+ teamName: string;
126
+ sortLabel: string;
127
+ keyLabel: string | null;
128
+ update: Update;
129
+ onOpen(picker: Picker): void;
130
+ }) {
131
+ const { theme, compact, state, update, onOpen } = props;
132
+ const { colors } = theme;
133
+ const [draft, setDraft] = useState(state.query);
134
+ useEffect(() => setDraft(state.query), [state.query]);
135
+ useEffect(() => {
136
+ const timer = setTimeout(() => {
137
+ if (draft !== state.query) update({ query: draft });
138
+ }, 300);
139
+ return () => clearTimeout(timer);
140
+ }, [draft, state.query, update]);
141
+ const styles = useMemo(() => {
142
+ const padding = compact ? 12 : 16;
143
+ return {
144
+ root: { paddingHorizontal: padding, paddingTop: padding, gap: 10 },
145
+ search: {
146
+ flex: 1,
147
+ height: 36,
148
+ flexDirection: "row",
149
+ alignItems: "center",
150
+ gap: 8,
151
+ paddingHorizontal: 10,
152
+ borderRadius: 8,
153
+ borderWidth: 1,
154
+ borderColor: colors.border,
155
+ backgroundColor: colors.surface1,
156
+ },
157
+ input: { flex: 1, height: 34, color: colors.foreground, fontSize: 14 },
158
+ } as const;
159
+ }, [colors, compact]);
160
+ const create = useCallback(() => update({ creating: true, issueId: null }), [update]);
161
+ const changeAssignee = useCallback((assignee: AssigneeFilter) => update({ assignee }), [update]);
162
+ const changeStatus = useCallback((status: StatusFilter) => update({ status }), [update]);
163
+ const toggleNested = useCallback(() => update({ nested: !state.nested }), [update, state.nested]);
164
+ const openTeams = useCallback(() => onOpen("team"), [onOpen]);
165
+ const openSort = useCallback(() => onOpen("sort"), [onOpen]);
166
+ const openKey = useCallback(() => onOpen("key"), [onOpen]);
167
+ return (
168
+ <View style={styles.root}>
169
+ <View style={ROW_STYLE}>
170
+ <View style={styles.search}>
171
+ <Icon name="Search" size={15} color={colors.foregroundMuted} />
172
+ <TextInput
173
+ value={draft}
174
+ onChangeText={setDraft}
175
+ placeholder="Search issues or jump to ENG-123"
176
+ placeholderTextColor={colors.foregroundMuted}
177
+ autoCapitalize="none"
178
+ autoCorrect={false}
179
+ accessibilityLabel="Search Linear issues"
180
+ style={styles.input}
181
+ />
182
+ </View>
183
+ <Button
184
+ theme={theme}
185
+ variant="primary"
186
+ icon="Plus"
187
+ label={compact ? "New" : "New issue"}
188
+ onPress={create}
189
+ />
190
+ </View>
191
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={FILTER_SCROLL_STYLE}>
192
+ <View style={ROW_STYLE}>
193
+ <Segmented
194
+ theme={theme}
195
+ accessibilityLabel="Assignee"
196
+ value={state.assignee}
197
+ options={ASSIGNEE_OPTIONS}
198
+ onChange={changeAssignee}
199
+ />
200
+ <Segmented
201
+ theme={theme}
202
+ accessibilityLabel="Status"
203
+ value={state.status}
204
+ options={STATUS_OPTIONS}
205
+ onChange={changeStatus}
206
+ />
207
+ <Button
208
+ theme={theme}
209
+ size="xs"
210
+ variant="ghost"
211
+ icon="Users"
212
+ label={props.teamName}
213
+ onPress={openTeams}
214
+ />
215
+ <Button
216
+ theme={theme}
217
+ size="xs"
218
+ variant="ghost"
219
+ icon="ArrowUpDown"
220
+ label={props.sortLabel}
221
+ accessibilityLabel={`Sort by ${props.sortLabel}`}
222
+ onPress={openSort}
223
+ />
224
+ <Button
225
+ theme={theme}
226
+ size="xs"
227
+ variant={state.nested ? "secondary" : "ghost"}
228
+ icon="ListTree"
229
+ label="Sub-issues"
230
+ accessibilityLabel={state.nested ? "Show sub-issues flat" : "Nest sub-issues"}
231
+ onPress={toggleNested}
232
+ />
233
+ {props.keyLabel ? (
234
+ <Button
235
+ theme={theme}
236
+ size="xs"
237
+ variant="ghost"
238
+ icon="KeyRound"
239
+ label={props.keyLabel}
240
+ accessibilityLabel={`Linear key: ${props.keyLabel}`}
241
+ onPress={openKey}
242
+ />
243
+ ) : null}
244
+ </View>
245
+ </ScrollView>
246
+ </View>
247
+ );
248
+ }
249
+
250
+ function IssueResults(props: {
251
+ theme: Theme;
252
+ compact: boolean;
253
+ state: BrowserState;
254
+ update: Update;
255
+ }) {
256
+ const { theme, compact, state, update } = props;
257
+ const { colors } = theme;
258
+ const issues = useIssues({
259
+ teamId: state.teamId,
260
+ assignee: state.assignee,
261
+ status: state.status,
262
+ query: state.query,
263
+ sort: state.sort,
264
+ });
265
+ const { refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = issues;
266
+ const collapsed = useMemo(() => new Set(state.collapsed), [state.collapsed]);
267
+ const rows = useMemo(
268
+ () =>
269
+ buildIssueRows(issues.data?.pages.flatMap((page) => page.issues) ?? [], {
270
+ nested: state.nested,
271
+ collapsed,
272
+ }),
273
+ [issues.data, state.nested, collapsed],
274
+ );
275
+ const styles = useMemo(
276
+ () =>
277
+ ({
278
+ list: { flex: 1, minHeight: 0 },
279
+ content: { paddingHorizontal: compact ? 4 : 8, paddingVertical: 8 },
280
+ footer: { color: colors.foregroundMuted, textAlign: "center", padding: 12 },
281
+ updating: {
282
+ color: colors.foregroundMuted,
283
+ fontSize: 12,
284
+ paddingHorizontal: compact ? 12 : 16,
285
+ paddingTop: 8,
286
+ },
287
+ }) as const,
288
+ [colors, compact],
289
+ );
290
+ const retry = useCallback(() => void refetch(), [refetch]);
291
+ const loadMore = useCallback(() => {
292
+ if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
293
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
294
+ const select = useCallback((issueId: string) => update({ issueId, creating: false }), [update]);
295
+ const toggle = useCallback(
296
+ (issueId: string) => update({ collapsed: toggleCollapsed(state.collapsed, issueId) }),
297
+ [update, state.collapsed],
298
+ );
299
+ const renderItem = useCallback(
300
+ ({ item }: ListRenderItemInfo<IssueListRow>) =>
301
+ item.kind === "header" ? (
302
+ <StateHeader theme={theme} state={item.state} count={item.count} />
303
+ ) : (
304
+ <IssueRow
305
+ theme={theme}
306
+ compact={compact}
307
+ issue={item.issue}
308
+ depth={item.depth}
309
+ childCount={item.childCount}
310
+ collapsed={item.collapsed}
311
+ nested={state.nested}
312
+ selected={item.issue.id === state.issueId}
313
+ onSelect={select}
314
+ onToggle={toggle}
315
+ />
316
+ ),
317
+ [theme, compact, state.issueId, state.nested, select, toggle],
318
+ );
319
+ const footer = useMemo(
320
+ () => (isFetchingNextPage ? <Text style={styles.footer}>Loading more…</Text> : null),
321
+ [isFetchingNextPage, styles.footer],
322
+ );
323
+
324
+ if (issues.isPending) return <EmptyState theme={theme} icon="RefreshCw" title="Loading issues" />;
325
+ if (issues.isError) {
326
+ return (
327
+ <EmptyState
328
+ theme={theme}
329
+ icon="TriangleAlert"
330
+ tone="danger"
331
+ title="Could not load issues"
332
+ detail={errorMessage(issues.error)}
333
+ >
334
+ <Button theme={theme} label="Try again" icon="RefreshCw" onPress={retry} />
335
+ </EmptyState>
336
+ );
337
+ }
338
+ if (rows.length === 0) {
339
+ return (
340
+ <EmptyState
341
+ theme={theme}
342
+ icon="Inbox"
343
+ title="No issues here"
344
+ detail={
345
+ state.query
346
+ ? "Try another search or widen the filters."
347
+ : "Change the filters, or create an issue."
348
+ }
349
+ />
350
+ );
351
+ }
352
+ return (
353
+ <>
354
+ {issues.isPlaceholderData ? (
355
+ <Text style={styles.updating} accessibilityLiveRegion="polite">
356
+ Showing saved results. Updating…
357
+ </Text>
358
+ ) : null}
359
+ <FlatList
360
+ style={styles.list}
361
+ contentContainerStyle={styles.content}
362
+ data={rows}
363
+ keyExtractor={rowKey}
364
+ refreshing={issues.isRefetching && !isFetchingNextPage}
365
+ onRefresh={retry}
366
+ onEndReachedThreshold={0.4}
367
+ onEndReached={loadMore}
368
+ renderItem={renderItem}
369
+ ListFooterComponent={footer}
370
+ />
371
+ </>
372
+ );
373
+ }
@@ -0,0 +1,152 @@
1
+ import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { useCallback, useMemo } from "react";
3
+ import { Pressable, Text, View } from "react-native";
4
+ import type { IssueSummary } from "../shared/linear";
5
+ import {
6
+ Avatar,
7
+ LabelPill,
8
+ PriorityIcon,
9
+ relativeTime,
10
+ StateIcon,
11
+ type Theme,
12
+ usePressableStyle,
13
+ } from "./ui";
14
+
15
+ export function StateHeader(props: { theme: Theme; state: IssueSummary["state"]; count: number }) {
16
+ const { colors } = props.theme;
17
+ const styles = useMemo(
18
+ () =>
19
+ ({
20
+ root: {
21
+ flexDirection: "row",
22
+ alignItems: "center",
23
+ gap: 8,
24
+ paddingHorizontal: 10,
25
+ paddingTop: 14,
26
+ paddingBottom: 6,
27
+ },
28
+ name: { color: colors.foreground, fontSize: 13, fontWeight: "500" },
29
+ count: { color: colors.foregroundMuted, fontSize: 12 },
30
+ }) as const,
31
+ [colors],
32
+ );
33
+ return (
34
+ <View style={styles.root}>
35
+ <StateIcon state={props.state} size={14} />
36
+ <Text style={styles.name}>{props.state.name}</Text>
37
+ <Text style={styles.count}>{props.count}</Text>
38
+ </View>
39
+ );
40
+ }
41
+
42
+ const INDENT = 18;
43
+
44
+ export function IssueRow(props: {
45
+ theme: Theme;
46
+ compact: boolean;
47
+ issue: IssueSummary;
48
+ depth: number;
49
+ childCount: number;
50
+ collapsed: boolean;
51
+ nested: boolean;
52
+ selected: boolean;
53
+ onSelect(issueId: string): void;
54
+ onToggle(issueId: string): void;
55
+ }) {
56
+ const { theme, issue, compact, selected, onSelect, onToggle, depth } = props;
57
+ const { colors } = theme;
58
+ const styles = useMemo(() => {
59
+ const row = {
60
+ minHeight: 40,
61
+ paddingLeft: 10 + depth * INDENT,
62
+ paddingRight: 10,
63
+ paddingVertical: 8,
64
+ borderRadius: 8,
65
+ flexDirection: "row",
66
+ alignItems: "center",
67
+ gap: 10,
68
+ backgroundColor: "transparent",
69
+ } as const;
70
+ const active = { ...row, backgroundColor: colors.surface2 };
71
+ return {
72
+ idle: selected ? active : row,
73
+ active,
74
+ identifier: { color: colors.foregroundMuted, fontSize: 12, width: 68 },
75
+ body: { flex: 1, minWidth: 0 },
76
+ title: { color: colors.foreground, fontSize: 14 },
77
+ meta: { color: colors.foregroundMuted, fontSize: 12 },
78
+ updated: { color: colors.foregroundMuted, fontSize: 12, minWidth: 56, textAlign: "right" },
79
+ chevron: { width: 18, height: 24, alignItems: "center", justifyContent: "center" },
80
+ count: { color: colors.foregroundMuted, fontSize: 12 },
81
+ } as const;
82
+ }, [colors, selected, depth]);
83
+ const pressStyle = usePressableStyle(styles.idle, styles.active);
84
+ const a11yState = useMemo(() => ({ selected }), [selected]);
85
+ const press = useCallback(() => onSelect(issue.id), [onSelect, issue.id]);
86
+ const toggle = useCallback(() => onToggle(issue.id), [onToggle, issue.id]);
87
+ const subIssues = `${props.childCount} sub-issue${props.childCount === 1 ? "" : "s"}`;
88
+ return (
89
+ <Pressable
90
+ accessibilityRole="button"
91
+ accessibilityLabel={`${issue.identifier} ${issue.title}, ${issue.state.name}`}
92
+ accessibilityState={a11yState}
93
+ onPress={press}
94
+ style={pressStyle}
95
+ >
96
+ {props.nested ? (
97
+ props.childCount > 0 ? (
98
+ <Pressable
99
+ accessibilityRole="button"
100
+ accessibilityLabel={`${props.collapsed ? "Show" : "Hide"} ${subIssues}`}
101
+ hitSlop={6}
102
+ onPress={toggle}
103
+ style={styles.chevron}
104
+ >
105
+ <Icon
106
+ name={props.collapsed ? "ChevronRight" : "ChevronDown"}
107
+ size={14}
108
+ color={colors.foregroundMuted}
109
+ />
110
+ </Pressable>
111
+ ) : (
112
+ <View style={styles.chevron} />
113
+ )
114
+ ) : null}
115
+ <PriorityIcon theme={theme} priority={issue.priority} size={15} />
116
+ {compact ? null : (
117
+ <Text numberOfLines={1} style={styles.identifier}>
118
+ {issue.identifier}
119
+ </Text>
120
+ )}
121
+ <StateIcon state={issue.state} size={15} />
122
+ <View style={styles.body}>
123
+ <Text numberOfLines={1} style={styles.title}>
124
+ {issue.title}
125
+ </Text>
126
+ {compact ? (
127
+ <Text numberOfLines={1} style={styles.meta}>
128
+ {issue.identifier} · {relativeTime(issue.updatedAt)}
129
+ </Text>
130
+ ) : null}
131
+ {props.collapsed ? (
132
+ <Text numberOfLines={1} style={styles.count}>
133
+ {subIssues} hidden
134
+ </Text>
135
+ ) : null}
136
+ </View>
137
+ {compact
138
+ ? null
139
+ : issue.labels
140
+ .slice(0, 2)
141
+ .map((label) => (
142
+ <LabelPill key={label.id} theme={theme} name={label.name} color={label.color} />
143
+ ))}
144
+ {compact ? null : (
145
+ <Text numberOfLines={1} style={styles.updated}>
146
+ {relativeTime(issue.updatedAt)}
147
+ </Text>
148
+ )}
149
+ <Avatar theme={theme} name={issue.assignee?.displayName ?? null} />
150
+ </Pressable>
151
+ );
152
+ }