@andrioid/paseo-attention-span 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/OVERVIEW.md +63 -0
- package/README.md +33 -2
- package/assets/favicon.svg +7 -0
- package/assets/icon.png +0 -0
- package/assets/logo.svg +7 -0
- package/client/attention-screen.tsx +948 -0
- package/client/coverage-settings.tsx +145 -0
- package/client/data.ts +22 -0
- package/client/icons.ts +45 -0
- package/client/next-up.tsx +161 -0
- package/client/sessions.ts +202 -0
- package/client/setup.tsx +134 -0
- package/client/sheet.tsx +87 -0
- package/client/ui.tsx +173 -0
- package/docs/screenshots/desktop-dark-doing.png +0 -0
- package/docs/screenshots/desktop-dark-next-up.png +0 -0
- package/docs/screenshots/phone-dark-doing.png +0 -0
- package/index.client.tsx +36 -0
- package/index.server.ts +49 -0
- package/package.json +45 -4
- package/paseo-plugin.json +14 -0
- package/server/actions.ts +90 -0
- package/server/classify.ts +448 -0
- package/server/context.ts +87 -0
- package/server/details.ts +76 -0
- package/server/fixture.ts +220 -0
- package/server/gh.ts +36 -0
- package/server/github-types.ts +114 -0
- package/server/markdown.ts +73 -0
- package/server/queries.ts +152 -0
- package/server/service.ts +248 -0
- package/server/setup.ts +47 -0
- package/server/store.ts +99 -0
- package/server/view.ts +37 -0
- package/shared/attention.ts +208 -0
- package/shared/config.ts +62 -0
- package/shared/format.ts +16 -0
- package/shared/prompts.ts +89 -0
- package/shared/rpc.ts +56 -0
- package/shared/setup.ts +27 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Attention, AttentionView, FeedId, FeedStatus, Item } from "../shared/attention";
|
|
2
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
3
|
+
import { AttentionConfig, githubRepoOf } from "../shared/config";
|
|
4
|
+
import { classify, classifyHistory } from "./classify";
|
|
5
|
+
import { FIXTURE, graphql } from "./gh";
|
|
6
|
+
import { fixture, FIXTURE_AGENTS } from "./fixture";
|
|
7
|
+
import type { Details, HistoryResult, LiveResult } from "./github-types";
|
|
8
|
+
import { detailsQuery, HISTORY_SEARCHES, LIVE_SEARCHES, type SearchName, searchQuery, searchVariables, unclaimedScope } from "./queries";
|
|
9
|
+
import { loadConfig, loadState, moveOldFiles, saveCodeRepo, saveCoverage, saveState, State } from "./store";
|
|
10
|
+
import { buildView } from "./view";
|
|
11
|
+
|
|
12
|
+
// History changes only when I finish something, so it's fetched daily, on ⟳ in its tab and after a merge.
|
|
13
|
+
const HISTORY_MAX_AGE_SECONDS = 24 * 60 * 60;
|
|
14
|
+
// A failed refresh is retried at most this often, except on ⟳.
|
|
15
|
+
const RETRY_MS = 5 * 60_000;
|
|
16
|
+
|
|
17
|
+
interface Feed {
|
|
18
|
+
inFlight: Promise<void> | null;
|
|
19
|
+
lastAttemptAt: number;
|
|
20
|
+
error: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Owns the cache and the background refresh of each feed [FRESH-01, PRIV-01].
|
|
24
|
+
export class AttentionService {
|
|
25
|
+
private state: State = State.parse({});
|
|
26
|
+
private config: AttentionConfig = AttentionConfig.parse({});
|
|
27
|
+
private configError: string | null = null;
|
|
28
|
+
private feeds: Record<FeedId, Feed> = {
|
|
29
|
+
live: { inFlight: null, lastAttemptAt: 0, error: null },
|
|
30
|
+
history: { inFlight: null, lastAttemptAt: 0, error: null },
|
|
31
|
+
};
|
|
32
|
+
private saving: Promise<void> = Promise.resolve();
|
|
33
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
private intervalMinutes = 0;
|
|
35
|
+
// The app's connection to Paseo, from its last request; background refreshes have none of their own.
|
|
36
|
+
private paseo: PluginHandlerContext["paseo"] | null = null;
|
|
37
|
+
|
|
38
|
+
async start(): Promise<void> {
|
|
39
|
+
await moveOldFiles().catch((error) => console.error("Couldn't move the old settings folder:", error));
|
|
40
|
+
this.state = await loadState();
|
|
41
|
+
await this.reloadConfig().catch(() => {}); // shown as the screen's error
|
|
42
|
+
this.schedule();
|
|
43
|
+
void this.refreshStale();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
connect(paseo: PluginHandlerContext["paseo"]): void {
|
|
47
|
+
this.paseo = paseo;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stop(): void {
|
|
51
|
+
if (this.timer) clearInterval(this.timer);
|
|
52
|
+
this.timer = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ⟳ fetches its tab's feed now; otherwise feeds older than their schedule are fetched first.
|
|
56
|
+
async get(refresh?: FeedId): Promise<AttentionView> {
|
|
57
|
+
if (refresh) await this.refresh(refresh).catch(() => {});
|
|
58
|
+
else await this.refreshStale();
|
|
59
|
+
return this.view();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async dismiss(key: string): Promise<AttentionView> {
|
|
63
|
+
const item = this.items().find((candidate) => candidate.key === key);
|
|
64
|
+
if (item) {
|
|
65
|
+
this.state.dismissed[key] = { at: item.othersActivityAt, section: item.section };
|
|
66
|
+
await this.save();
|
|
67
|
+
}
|
|
68
|
+
return this.view();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async undismiss(key: string): Promise<AttentionView> {
|
|
72
|
+
delete this.state.dismissed[key];
|
|
73
|
+
await saveState(this.state);
|
|
74
|
+
return this.view();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async restoreDismissed(): Promise<AttentionView> {
|
|
78
|
+
this.state.dismissed = {};
|
|
79
|
+
await this.save();
|
|
80
|
+
return this.view();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Remember where an issue repo's code lives [SES-07].
|
|
84
|
+
async setCodeRepo(issueRepo: string, codeRepo: string): Promise<AttentionView> {
|
|
85
|
+
this.config = await saveCodeRepo(issueRepo, codeRepo);
|
|
86
|
+
return this.view();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Orgs and repos chosen in settings [SRC-02]. The lists follow right away; History catches up behind them.
|
|
90
|
+
async setCoverage(orgs: string[], repos: string[]): Promise<void> {
|
|
91
|
+
this.config = await saveCoverage(orgs, repos);
|
|
92
|
+
void this.refresh("history").catch(() => {});
|
|
93
|
+
await this.refresh("live").catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async setScopes(scopes: string[]): Promise<void> {
|
|
97
|
+
this.state.scopes = scopes;
|
|
98
|
+
await this.save();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Concurrent callers share one refresh per feed.
|
|
102
|
+
refresh(id: FeedId): Promise<void> {
|
|
103
|
+
const feed = this.feeds[id];
|
|
104
|
+
feed.inFlight ??= this.fetch(id).finally(() => (feed.inFlight = null));
|
|
105
|
+
return feed.inFlight;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private refreshStale(): Promise<unknown> {
|
|
109
|
+
const stale = (["live", "history"] as const).filter((id) => this.isStale(id));
|
|
110
|
+
return Promise.all(stale.map((id) => this.refresh(id).catch(() => {})));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async fetch(id: FeedId): Promise<void> {
|
|
114
|
+
const feed = this.feeds[id];
|
|
115
|
+
feed.lastAttemptAt = Date.now();
|
|
116
|
+
try {
|
|
117
|
+
await this.reloadConfig();
|
|
118
|
+
const now = new Date();
|
|
119
|
+
const fetchedAt = now.toISOString();
|
|
120
|
+
if (id === "live") this.state.live = { fetchedAt, attention: await this.fetchLive(now) };
|
|
121
|
+
else this.state.history = { fetchedAt, items: await this.fetchHistory(now) };
|
|
122
|
+
// Forget dismissals of items that are gone.
|
|
123
|
+
const keys = new Set(this.items().map((item) => item.key));
|
|
124
|
+
for (const key of Object.keys(this.state.dismissed)) if (!keys.has(key)) delete this.state.dismissed[key];
|
|
125
|
+
feed.error = null;
|
|
126
|
+
await this.save();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
feed.error = (error as Error).message;
|
|
129
|
+
console.error(`Attention Span ${id} refresh failed:`, feed.error);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async fetchLive(now: Date): Promise<Attention> {
|
|
135
|
+
const projectRepos = FIXTURE ? [] : await this.projectRepos();
|
|
136
|
+
const { data, details } = FIXTURE ? fixture(now) : await this.searchLive(now, projectRepos);
|
|
137
|
+
return { ...classify({ data, details, config: this.config, now }), unclaimedFrom: unclaimedScope(this.config, projectRepos).from };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// My Paseo projects' GitHub repos, where Next up looks when no repos are chosen [NXT-08].
|
|
141
|
+
// Without a connection yet, or if listing fails, the last list is used.
|
|
142
|
+
private async projectRepos(): Promise<string[]> {
|
|
143
|
+
if (this.paseo) {
|
|
144
|
+
try {
|
|
145
|
+
const { projects } = await this.paseo.projects.list();
|
|
146
|
+
this.state.projectRepos = [...new Set(projects.map((project) => githubRepoOf(project.projectKey)).filter((repo): repo is string => !!repo))].sort();
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error("Couldn't list Paseo projects:", error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return this.state.projectRepos;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async fetchHistory(now: Date): Promise<Item[]> {
|
|
155
|
+
if (FIXTURE) return classifyHistory(fixture(now).data);
|
|
156
|
+
return classifyHistory(await graphql<HistoryResult>(searchQuery(HISTORY_SEARCHES), this.variables(HISTORY_SEARCHES, now)));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// One request per search, in parallel. The details request starts once the searches it needs are back [GH-01].
|
|
160
|
+
private async searchLive(now: Date, projectRepos: string[]): Promise<{ data: LiveResult; details: Details }> {
|
|
161
|
+
const all = searchVariables(this.config, now, projectRepos);
|
|
162
|
+
const search = async (name: (typeof LIVE_SEARCHES)[number]): Promise<Partial<LiveResult>> =>
|
|
163
|
+
all[name] === null ? { [name]: { nodes: [] } } : graphql<Partial<LiveResult>>(searchQuery([name], name === "mine"), { [name]: all[name] });
|
|
164
|
+
const merge = (parts: Partial<LiveResult>[]) => Object.assign({}, ...parts) as LiveResult;
|
|
165
|
+
const forDetails = Promise.all([search("mine"), search("assigned"), search("unclaimed")]);
|
|
166
|
+
const others = Promise.all([search("reviews"), search("mentions"), search("commented")]);
|
|
167
|
+
const afterThem = forDetails.then((parts) => this.details(merge(parts)));
|
|
168
|
+
const [first, rest, details] = await Promise.all([forDetails, others, afterThem]);
|
|
169
|
+
return { data: merge([...first, ...rest]), details };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private variables(names: readonly SearchName[], now: Date): Record<string, string | null> {
|
|
173
|
+
const all = searchVariables(this.config, now);
|
|
174
|
+
return Object.fromEntries(names.map((name) => [name, all[name]]));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Behind counts [UPS-07] and issue field option order [NXT-02].
|
|
178
|
+
private async details(data: Pick<LiveResult, "mine" | "assigned" | "unclaimed">): Promise<Details> {
|
|
179
|
+
const prs = data.mine.nodes.filter((pr) => pr?.headRefName && !pr.isCrossRepository);
|
|
180
|
+
const { priority, effort } = this.config.nextUp;
|
|
181
|
+
const needsOptions = [priority, effort].some((field) => field && !field.order);
|
|
182
|
+
const issues = [...data.unclaimed.nodes, ...data.assigned.nodes];
|
|
183
|
+
const orgs = needsOptions ? [...new Set(issues.map((issue) => issue?.repository?.nameWithOwner.split("/")[0]).filter(Boolean))] : [];
|
|
184
|
+
const details: Details = { behind: {}, fieldOptions: {} };
|
|
185
|
+
if (prs.length === 0 && orgs.length === 0) return details;
|
|
186
|
+
const { query, compares } = detailsQuery(prs, orgs as string[]);
|
|
187
|
+
type Result = { ref?: { compare: { behindBy: number } | null } | null; issueFields?: { nodes: { name?: string; options?: { name: string }[] }[] } } | null;
|
|
188
|
+
const result = await graphql<Record<string, Result>>(query);
|
|
189
|
+
for (const [alias, value] of Object.entries(result)) {
|
|
190
|
+
const behindBy = value?.ref?.compare?.behindBy;
|
|
191
|
+
if (typeof behindBy === "number") details.behind[compares[alias]] = behindBy;
|
|
192
|
+
for (const field of value?.issueFields?.nodes ?? []) if (field.name && field.options) details.fieldOptions[field.name] ??= field.options.map((o) => o.name);
|
|
193
|
+
}
|
|
194
|
+
return details;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async reloadConfig(): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
this.config = await loadConfig();
|
|
200
|
+
this.configError = null;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.configError = (error as Error).message;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
if (this.timer && this.intervalMinutes !== this.config.refreshMinutes) this.schedule();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private schedule(): void {
|
|
209
|
+
this.stop();
|
|
210
|
+
this.intervalMinutes = this.config.refreshMinutes;
|
|
211
|
+
this.timer = setInterval(() => {
|
|
212
|
+
void this.refresh("live").catch(() => {});
|
|
213
|
+
if (this.isStale("history")) void this.refresh("history").catch(() => {});
|
|
214
|
+
}, this.intervalMinutes * 60_000);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Older than the feed's schedule, and not failed within the last few minutes.
|
|
218
|
+
private isStale(id: FeedId): boolean {
|
|
219
|
+
const maxAgeSeconds = id === "live" ? this.config.refreshMinutes * 60 : HISTORY_MAX_AGE_SECONDS;
|
|
220
|
+
const fetchedAt = this.state[id]?.fetchedAt;
|
|
221
|
+
const old = !fetchedAt || Date.now() - new Date(fetchedAt).getTime() >= maxAgeSeconds * 1000;
|
|
222
|
+
return old && Date.now() - this.feeds[id].lastAttemptAt >= RETRY_MS;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private items(): Item[] {
|
|
226
|
+
return [...(this.state.live?.attention.items ?? []), ...(this.state.history?.items ?? [])];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Feeds finish independently; saves go one at a time so an older snapshot never lands last.
|
|
230
|
+
private save(): Promise<void> {
|
|
231
|
+
this.saving = this.saving.catch(() => {}).then(() => saveState(this.state));
|
|
232
|
+
return this.saving;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private view(): AttentionView {
|
|
236
|
+
const status = (id: FeedId): FeedStatus => ({
|
|
237
|
+
fetchedAt: this.state[id]?.fetchedAt ?? null,
|
|
238
|
+
refreshing: this.feeds[id].inFlight !== null,
|
|
239
|
+
error: this.feeds[id].error ?? this.configError,
|
|
240
|
+
});
|
|
241
|
+
return buildView(this.state.live?.attention ?? null, this.state.history?.items ?? [], this.state.dismissed, {
|
|
242
|
+
feeds: { live: status("live"), history: status("history") },
|
|
243
|
+
scopes: this.state.scopes,
|
|
244
|
+
config: this.config,
|
|
245
|
+
...(FIXTURE ? { fixtureAgents: FIXTURE_AGENTS } : {}),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
package/server/setup.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ConfigStatus, CoverageCheck, CoverageKind, SetupStatus } from "../shared/setup";
|
|
2
|
+
import { FIXTURE_ORGS, FIXTURE_REPOS } from "./fixture";
|
|
3
|
+
import { FIXTURE, runGh } from "./gh";
|
|
4
|
+
import { loadConfig } from "./store";
|
|
5
|
+
|
|
6
|
+
// What the setup checklist needs [SET-01]. Asked only when Attention Span can't load.
|
|
7
|
+
export async function setupStatus(): Promise<SetupStatus> {
|
|
8
|
+
return { gh: FIXTURE ? { found: true, login: "me", error: null } : await ghStatus(), config: await configStatus() };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// `gh api user` answers both questions at once: is gh there, and who is it signed in as.
|
|
12
|
+
async function ghStatus(): Promise<SetupStatus["gh"]> {
|
|
13
|
+
try {
|
|
14
|
+
const { code, stdout, stderr } = await runGh(["api", "user", "--jq", ".login"]);
|
|
15
|
+
const login = stdout.trim();
|
|
16
|
+
return code === 0 && login ? { found: true, login, error: null } : { found: true, login: null, error: stderr.trim() || `gh exited with ${code}` };
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return { found: false, login: null, error: (error as Error).message };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function configStatus(): Promise<ConfigStatus> {
|
|
23
|
+
try {
|
|
24
|
+
const { orgs, repos } = await loadConfig();
|
|
25
|
+
return { error: null, orgs, repos };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return { error: (error as Error).message, orgs: [], repos: [] };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// An org or repo is checked on GitHub before it's saved, so a typo shows right away [SRC-02].
|
|
32
|
+
// Pasted GitHub URLs are accepted. The exact name GitHub returns is what gets saved.
|
|
33
|
+
export async function checkCoverage(kind: CoverageKind, input: string): Promise<CoverageCheck> {
|
|
34
|
+
const name = input.trim().replace(/^(https?:\/\/)?github\.com\//i, "").replace(/\/+$/, "");
|
|
35
|
+
if (kind === "org" && !/^[\w.-]+$/.test(name)) return { name: null, error: "Use the organization's name, like acme" };
|
|
36
|
+
if (kind === "repo" && !/^[\w.-]+\/[\w.-]+$/.test(name)) return { name: null, error: "Use owner/name, like acme/shop" };
|
|
37
|
+
if (FIXTURE) {
|
|
38
|
+
const known = (kind === "org" ? FIXTURE_ORGS : FIXTURE_REPOS).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
39
|
+
return known ? { name: known, error: null } : { name: null, error: `${name} wasn't found on GitHub` };
|
|
40
|
+
}
|
|
41
|
+
const { code, stdout, stderr } = await runGh(["api", kind === "org" ? `users/${name}` : `repos/${name}`, "--jq", kind === "org" ? ".login + \" \" + .type" : ".full_name"]);
|
|
42
|
+
if (code !== 0) return { name: null, error: /404|Not Found/i.test(stderr) ? `${name} wasn't found on GitHub, or gh's account can't see it` : stderr.trim() || `gh exited with ${code}` };
|
|
43
|
+
if (kind === "repo") return { name: stdout.trim(), error: null };
|
|
44
|
+
// Searches use org:, which matches organizations only.
|
|
45
|
+
const [login, type] = stdout.trim().split(" ");
|
|
46
|
+
return type === "Organization" ? { name: login, error: null } : { name: null, error: `${login} is a person, not an organization. Add their repos instead` };
|
|
47
|
+
}
|
package/server/store.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { Attention, Item, SectionId } from "../shared/attention";
|
|
6
|
+
import { AttentionConfig } from "../shared/config";
|
|
7
|
+
|
|
8
|
+
// Local files in ~/.config/paseo-attention-span/ [PRIV-01].
|
|
9
|
+
const CHOSEN_DIR = process.env.PASEO_ATTENTION_SPAN_DIR ?? process.env.PASEO_GITHUB_ATTENTION_DIR;
|
|
10
|
+
export const DIR = CHOSEN_DIR ?? join(homedir(), ".config", "paseo-attention-span");
|
|
11
|
+
// Where they lived while the plugin was called "GitHub attention".
|
|
12
|
+
const OLD_DIR = join(homedir(), ".config", "paseo-github-attention");
|
|
13
|
+
const CONFIG = join(DIR, "config.json");
|
|
14
|
+
const STATE = join(DIR, "state.json");
|
|
15
|
+
|
|
16
|
+
export const State = z.object({
|
|
17
|
+
// Item key -> others' activity and section when dismissed [ATT-08].
|
|
18
|
+
dismissed: z.record(z.string(), z.object({ at: z.string(), section: z.custom<SectionId>() })).default({}),
|
|
19
|
+
// Scope chips picked on Next up; none means all [NXT-03].
|
|
20
|
+
scopes: z.array(z.string()).default([]),
|
|
21
|
+
// My Paseo projects' GitHub repos when last listed, for refreshes before the app has connected [NXT-08].
|
|
22
|
+
projectRepos: z.array(z.string()).default([]),
|
|
23
|
+
// The last fetch of each feed. A cache from an older version is dropped rather than shown wrong.
|
|
24
|
+
live: z.object({ fetchedAt: z.string(), attention: z.custom<Attention>((value) => Array.isArray((value as Attention)?.unclaimed)) }).nullable().catch(null),
|
|
25
|
+
history: z.object({ fetchedAt: z.string(), items: z.custom<Item[]>(Array.isArray) }).nullable().catch(null),
|
|
26
|
+
});
|
|
27
|
+
export type State = z.infer<typeof State>;
|
|
28
|
+
|
|
29
|
+
// Moves the old folder over once, unless a folder is chosen by environment variable.
|
|
30
|
+
export async function moveOldFiles(): Promise<void> {
|
|
31
|
+
if (CHOSEN_DIR || (await exists(DIR)) || !(await exists(OLD_DIR))) return;
|
|
32
|
+
await rename(OLD_DIR, DIR);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function exists(path: string): Promise<boolean> {
|
|
36
|
+
return stat(path).then(
|
|
37
|
+
() => true,
|
|
38
|
+
() => false,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readJson(path: string): Promise<unknown> {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
47
|
+
throw new Error(`Can't read ${path}: ${(error as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function loadConfig(): Promise<AttentionConfig> {
|
|
52
|
+
const parsed = AttentionConfig.safeParse(await readJson(CONFIG));
|
|
53
|
+
if (!parsed.success) throw new Error(`${CONFIG} is invalid: ${parsed.error.message}`);
|
|
54
|
+
return parsed.data;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Where an issue repo's code lives, chosen on the screen [SES-07].
|
|
58
|
+
export function saveCodeRepo(issueRepo: string, codeRepo: string): Promise<AttentionConfig> {
|
|
59
|
+
return updateConfig((file) => {
|
|
60
|
+
const existing = file.codeRepos && typeof file.codeRepos === "object" ? (file.codeRepos as Record<string, unknown>) : {};
|
|
61
|
+
const codeRepos = Object.fromEntries(Object.entries(existing).filter(([issues]) => issues.toLowerCase() !== issueRepo.toLowerCase()));
|
|
62
|
+
return { ...file, codeRepos: { ...codeRepos, [issueRepo]: codeRepo } };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The orgs and repos chosen in settings [SRC-02].
|
|
67
|
+
export function saveCoverage(orgs: string[], repos: string[]): Promise<AttentionConfig> {
|
|
68
|
+
return updateConfig((file) => ({ ...file, orgs, repos }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// What the screen writes changes only its own keys; everything else in the file is kept as it is.
|
|
72
|
+
// A file that doesn't read or parse is left alone.
|
|
73
|
+
async function updateConfig(change: (file: Record<string, unknown>) => Record<string, unknown>): Promise<AttentionConfig> {
|
|
74
|
+
const raw = await readJson(CONFIG);
|
|
75
|
+
const current = AttentionConfig.safeParse(raw);
|
|
76
|
+
if (!current.success) throw new Error(`${CONFIG} is invalid: ${current.error.message}`);
|
|
77
|
+
const next = change(raw as Record<string, unknown>);
|
|
78
|
+
const parsed = AttentionConfig.safeParse(next);
|
|
79
|
+
if (!parsed.success) throw new Error(`${CONFIG} is invalid: ${parsed.error.message}`);
|
|
80
|
+
await writeAtomically(CONFIG, `${JSON.stringify(next, null, 2)}\n`);
|
|
81
|
+
return parsed.data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function writeAtomically(path: string, content: string): Promise<void> {
|
|
85
|
+
await mkdir(DIR, { recursive: true });
|
|
86
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
87
|
+
await writeFile(temporary, content);
|
|
88
|
+
await rename(temporary, path);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function loadState(): Promise<State> {
|
|
92
|
+
const parsed = State.safeParse(await readJson(STATE).catch(() => ({})));
|
|
93
|
+
// A cache that no longer parses (e.g. after an update) is simply dropped.
|
|
94
|
+
return parsed.success ? parsed.data : State.parse({});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function saveState(state: State): Promise<void> {
|
|
98
|
+
await writeAtomically(STATE, JSON.stringify(state, null, 2));
|
|
99
|
+
}
|
package/server/view.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Attention, type AttentionView, type FeedId, type FeedStatus, type Item, type Section, SECTION_ORDER, SECTIONS, type TabId } from "../shared/attention";
|
|
2
|
+
import type { AttentionConfig } from "../shared/config";
|
|
3
|
+
import type { State } from "./store";
|
|
4
|
+
|
|
5
|
+
export interface ViewMeta {
|
|
6
|
+
feeds: Record<FeedId, FeedStatus>;
|
|
7
|
+
scopes: string[];
|
|
8
|
+
config: AttentionConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A dismissed item stays hidden until someone else does something on it or its reason changes [ATT-08].
|
|
12
|
+
export function isDismissed(item: Item, dismissed: State["dismissed"]): boolean {
|
|
13
|
+
const entry = dismissed[item.key];
|
|
14
|
+
return !!entry && entry.section === item.section && item.othersActivityAt <= entry.at;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildView(attention: Attention | null, history: Item[], dismissed: State["dismissed"], meta: ViewMeta): AttentionView {
|
|
18
|
+
const items = [...(attention?.items ?? []), ...history];
|
|
19
|
+
const visible = items.filter((item) => !isDismissed(item, dismissed));
|
|
20
|
+
const sections = (tab: TabId): Section[] =>
|
|
21
|
+
SECTION_ORDER.filter((id) => SECTIONS[id].tab === tab)
|
|
22
|
+
.map((id) => ({ id, title: SECTIONS[id].title, items: visible.filter((item) => item.section === id) }))
|
|
23
|
+
.filter((section) => section.items.length > 0);
|
|
24
|
+
const doing = sections("doing");
|
|
25
|
+
return {
|
|
26
|
+
...meta,
|
|
27
|
+
login: attention?.login ?? null,
|
|
28
|
+
count: doing.reduce((sum, section) => sum + section.items.length, 0),
|
|
29
|
+
doing,
|
|
30
|
+
waiting: sections("waiting"),
|
|
31
|
+
history: visible.filter((item) => SECTIONS[item.section].tab === "history"),
|
|
32
|
+
assigned: attention?.assigned ?? [],
|
|
33
|
+
unclaimed: attention?.unclaimed ?? [],
|
|
34
|
+
unclaimedFrom: attention?.unclaimedFrom ?? "config",
|
|
35
|
+
dismissedCount: items.length - visible.length,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { AttentionConfig, MergeMethod } from "./config";
|
|
2
|
+
|
|
3
|
+
// Tabs tell the story of my work: what I finished, what needs me, what waits on others, what's next.
|
|
4
|
+
export type TabId = "history" | "doing" | "waiting" | "next";
|
|
5
|
+
|
|
6
|
+
// Each tab's data comes from one feed, fetched from GitHub on its own schedule. History changes only when I finish something.
|
|
7
|
+
export type FeedId = "live" | "history";
|
|
8
|
+
export const TAB_FEED: Record<TabId, FeedId> = { history: "history", doing: "live", waiting: "live", next: "live" };
|
|
9
|
+
|
|
10
|
+
// Within a tab, top to bottom; an item appears once, under its most urgent reason [ATT-02].
|
|
11
|
+
export const SECTIONS = {
|
|
12
|
+
reply: { tab: "doing", title: "Waiting for your reply" },
|
|
13
|
+
review: { tab: "doing", title: "Review requested" },
|
|
14
|
+
ci: { tab: "doing", title: "CI failing" },
|
|
15
|
+
changes: { tab: "doing", title: "Changes requested" },
|
|
16
|
+
ready: { tab: "doing", title: "Ready to merge" },
|
|
17
|
+
upstream: { tab: "doing", title: "Needs upstream" },
|
|
18
|
+
too_long: { tab: "doing", title: "Waiting too long" },
|
|
19
|
+
drafts: { tab: "doing", title: "My drafts" },
|
|
20
|
+
// [WAI-01]
|
|
21
|
+
wait_review: { tab: "waiting", title: "Waiting for review" },
|
|
22
|
+
wait_reviewer: { tab: "waiting", title: "Waiting for the reviewer" },
|
|
23
|
+
wait_ci: { tab: "waiting", title: "CI running" },
|
|
24
|
+
wait_answer: { tab: "waiting", title: "Questions I asked" },
|
|
25
|
+
// [HIS-01, HIS-02]
|
|
26
|
+
done: { tab: "history", title: "Done" },
|
|
27
|
+
reviewed: { tab: "history", title: "Reviewed" },
|
|
28
|
+
} as const satisfies Record<string, { tab: TabId; title: string }>;
|
|
29
|
+
export type SectionId = keyof typeof SECTIONS;
|
|
30
|
+
export const SECTION_ORDER = Object.keys(SECTIONS) as SectionId[];
|
|
31
|
+
|
|
32
|
+
// The first is the card's main action; the item's sheet lists them all.
|
|
33
|
+
export type ActionId =
|
|
34
|
+
| "reply"
|
|
35
|
+
| "acknowledge"
|
|
36
|
+
| "review"
|
|
37
|
+
| "fix"
|
|
38
|
+
| "rerun"
|
|
39
|
+
| "address"
|
|
40
|
+
| "update"
|
|
41
|
+
| "rebase"
|
|
42
|
+
| "refresh"
|
|
43
|
+
| "ready"
|
|
44
|
+
| "automerge"
|
|
45
|
+
| "merge"
|
|
46
|
+
| "close"
|
|
47
|
+
| "start";
|
|
48
|
+
|
|
49
|
+
// A short state label on a row [ATT-05]; tone picks its color.
|
|
50
|
+
export interface Chip {
|
|
51
|
+
text: string;
|
|
52
|
+
tone: "good" | "bad" | "warn" | "neutral";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The person (or bot) I'm waiting on, or who is waiting on me.
|
|
56
|
+
export interface Person {
|
|
57
|
+
login: string;
|
|
58
|
+
avatarUrl?: string;
|
|
59
|
+
bot: boolean;
|
|
60
|
+
since: string;
|
|
61
|
+
snippet?: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
// Mentions only: every comment still waiting on me, for 👍 [MEN-02].
|
|
64
|
+
comments?: WaitingComment[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface WaitingComment {
|
|
68
|
+
// The comment's node, or the issue or PR when its description mentions me.
|
|
69
|
+
id: string;
|
|
70
|
+
author: string;
|
|
71
|
+
body: string;
|
|
72
|
+
createdAt: string;
|
|
73
|
+
url?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What a session or GitHub action works on.
|
|
77
|
+
export interface Target {
|
|
78
|
+
kind: "pr" | "issue";
|
|
79
|
+
repo: string;
|
|
80
|
+
number: number;
|
|
81
|
+
title: string;
|
|
82
|
+
url: string;
|
|
83
|
+
// Pull requests only.
|
|
84
|
+
id?: string;
|
|
85
|
+
headRefName?: string;
|
|
86
|
+
baseRefName?: string;
|
|
87
|
+
headRefOid?: string;
|
|
88
|
+
defaultBranch?: string;
|
|
89
|
+
mergeMethod?: MergeMethod;
|
|
90
|
+
// Every PR in the stack, bottom first.
|
|
91
|
+
stack?: { number: number; head: string; base: string }[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SubRow {
|
|
95
|
+
key: string;
|
|
96
|
+
number: number;
|
|
97
|
+
title: string;
|
|
98
|
+
url: string;
|
|
99
|
+
draft: boolean;
|
|
100
|
+
chips: Chip[];
|
|
101
|
+
// Behind its parent, conflicting or failing CI: shown even when the stack is collapsed.
|
|
102
|
+
attention: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Item {
|
|
106
|
+
key: string;
|
|
107
|
+
section: SectionId;
|
|
108
|
+
kind: "pr" | "issue";
|
|
109
|
+
draft: boolean;
|
|
110
|
+
repo: string;
|
|
111
|
+
number: number;
|
|
112
|
+
title: string;
|
|
113
|
+
url: string;
|
|
114
|
+
chips: Chip[];
|
|
115
|
+
person?: Person;
|
|
116
|
+
actions: ActionId[];
|
|
117
|
+
// Linked PRs under an issue, or the PRs above the bottom of a stack.
|
|
118
|
+
children: SubRow[];
|
|
119
|
+
target: Target;
|
|
120
|
+
// Latest activity by someone else, for bringing back dismissed items [ATT-08].
|
|
121
|
+
othersActivityAt: string;
|
|
122
|
+
// History only: when it merged or closed.
|
|
123
|
+
closedAt?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// An issue to pick up: assigned to me [NXT-06] or nobody [NXT-01].
|
|
127
|
+
export interface NextUpIssue {
|
|
128
|
+
key: string;
|
|
129
|
+
repo: string;
|
|
130
|
+
number: number;
|
|
131
|
+
title: string;
|
|
132
|
+
url: string;
|
|
133
|
+
createdAt: string;
|
|
134
|
+
type?: string;
|
|
135
|
+
priority?: string;
|
|
136
|
+
effort?: string;
|
|
137
|
+
scope?: string;
|
|
138
|
+
defaultBranch?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// More about an item, fetched when its sheet opens rather than in every refresh [GH-01].
|
|
142
|
+
export interface ItemDetails {
|
|
143
|
+
// The PR actions apply to.
|
|
144
|
+
size?: { additions: number; deletions: number; files: number };
|
|
145
|
+
// The issues a PR closes, or the PRs that close an issue.
|
|
146
|
+
linked: { number: number; title: string; url: string; state: string }[];
|
|
147
|
+
// Only asked for when the item offers Rerun: each PR in the stack with failing checks.
|
|
148
|
+
failing: FailingChecks[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface FailingChecks {
|
|
152
|
+
pr: number;
|
|
153
|
+
// Checks from GitHub Actions can be rerun; other status checks can't.
|
|
154
|
+
checks: { name: string; rerunnable: boolean }[];
|
|
155
|
+
runs: { id: number; attempt: number }[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Where Next up's unclaimed issues came from: the config, my Paseo projects' repos, or nowhere [NXT-08].
|
|
159
|
+
export type UnclaimedSource = "config" | "projects" | "none";
|
|
160
|
+
|
|
161
|
+
export interface Attention {
|
|
162
|
+
login: string;
|
|
163
|
+
items: Item[];
|
|
164
|
+
assigned: NextUpIssue[];
|
|
165
|
+
unclaimed: NextUpIssue[];
|
|
166
|
+
unclaimedFrom?: UnclaimedSource;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface Section {
|
|
170
|
+
id: SectionId;
|
|
171
|
+
title: string;
|
|
172
|
+
items: Item[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A Paseo session started from an item [ATT-07].
|
|
176
|
+
export interface AgentSummary {
|
|
177
|
+
id: string;
|
|
178
|
+
status: string;
|
|
179
|
+
requiresAttention: boolean;
|
|
180
|
+
title: string | null;
|
|
181
|
+
workspaceId: string | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface FeedStatus {
|
|
185
|
+
fetchedAt: string | null;
|
|
186
|
+
refreshing: boolean;
|
|
187
|
+
error: string | null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// What the client renders: dismissed items removed, grouped per tab.
|
|
191
|
+
export interface AttentionView {
|
|
192
|
+
feeds: Record<FeedId, FeedStatus>;
|
|
193
|
+
login: string | null;
|
|
194
|
+
// Items in Doing; the sidebar shows it [ATT-09].
|
|
195
|
+
count: number;
|
|
196
|
+
doing: Section[];
|
|
197
|
+
waiting: Section[];
|
|
198
|
+
history: Item[];
|
|
199
|
+
assigned: NextUpIssue[];
|
|
200
|
+
unclaimed: NextUpIssue[];
|
|
201
|
+
unclaimedFrom: UnclaimedSource;
|
|
202
|
+
dismissedCount: number;
|
|
203
|
+
// Scope chips picked on Next up; none means all [NXT-03].
|
|
204
|
+
scopes: string[];
|
|
205
|
+
config: AttentionConfig;
|
|
206
|
+
// Fixture mode only: made-up sessions, since the preview daemon has no agents.
|
|
207
|
+
fixtureAgents?: Record<string, AgentSummary>;
|
|
208
|
+
}
|