@andrioid/paseo-attention-span 0.0.0-stage → 0.1.1
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,220 @@
|
|
|
1
|
+
import type { AgentSummary, ItemDetails, Target } from "../shared/attention";
|
|
2
|
+
import type { Actor, Comment, Details, IssueNode, MentionNode, PickableNode, PullRequestNode, SearchResult } from "./github-types";
|
|
3
|
+
|
|
4
|
+
// Made-up GitHub data for tests and for previews without GitHub (PASEO_ATTENTION_SPAN_FIXTURE=1).
|
|
5
|
+
|
|
6
|
+
export const REPO = "acme/shop";
|
|
7
|
+
// Issues kept apart from the code, as some orgs do; its code lives in REPO [SES-07].
|
|
8
|
+
export const ISSUES_REPO = "acme/issues";
|
|
9
|
+
// What the settings screen can add in previews [SRC-02]; anything else isn't found.
|
|
10
|
+
export const FIXTURE_ORGS = ["acme"];
|
|
11
|
+
export const FIXTURE_REPOS = [REPO, ISSUES_REPO, "acme/web"];
|
|
12
|
+
const repository = { nameWithOwner: REPO, squashMergeAllowed: true, defaultBranchRef: { name: "main" } };
|
|
13
|
+
const avatar = (id: number) => `https://avatars.githubusercontent.com/u/${id}?v=4`;
|
|
14
|
+
|
|
15
|
+
export const me: Actor = { __typename: "User", login: "me" };
|
|
16
|
+
export const alice: Actor = { __typename: "User", login: "alice", avatarUrl: avatar(1) };
|
|
17
|
+
export const bob: Actor = { __typename: "User", login: "bob", avatarUrl: avatar(2) };
|
|
18
|
+
export const bot: Actor = { __typename: "Bot", login: "reviewbot" };
|
|
19
|
+
|
|
20
|
+
export function builders(now: Date) {
|
|
21
|
+
const daysAgo = (days: number) => new Date(now.getTime() - days * 86_400_000).toISOString();
|
|
22
|
+
const commit = (ci: string | null, days = 1) => ({ nodes: [{ commit: { committedDate: daysAgo(days), statusCheckRollup: ci ? { state: ci } : null } }] });
|
|
23
|
+
const thread = (author: Actor, days: number, isResolved = false) => ({ isResolved, comments: { nodes: [comment(author, "", days)] } });
|
|
24
|
+
let comments = 0;
|
|
25
|
+
|
|
26
|
+
function pr(number: number, overrides: Partial<PullRequestNode> = {}, ci: string | null = "SUCCESS"): PullRequestNode {
|
|
27
|
+
return {
|
|
28
|
+
id: `PR_${number}`,
|
|
29
|
+
number,
|
|
30
|
+
title: `PR ${number}`,
|
|
31
|
+
url: `https://github.com/${REPO}/pull/${number}`,
|
|
32
|
+
isDraft: false,
|
|
33
|
+
createdAt: daysAgo(2),
|
|
34
|
+
updatedAt: daysAgo(1),
|
|
35
|
+
author: me,
|
|
36
|
+
repository,
|
|
37
|
+
baseRefName: "main",
|
|
38
|
+
headRefName: `branch-${number}`,
|
|
39
|
+
headRefOid: `oid${number}`,
|
|
40
|
+
mergeable: "MERGEABLE",
|
|
41
|
+
reviewDecision: "REVIEW_REQUIRED",
|
|
42
|
+
assignees: { nodes: [] },
|
|
43
|
+
closingIssuesReferences: { nodes: [] },
|
|
44
|
+
commits: commit(ci),
|
|
45
|
+
reviewRequests: { nodes: [] },
|
|
46
|
+
latestReviews: { nodes: [] },
|
|
47
|
+
reviewThreads: { nodes: [] },
|
|
48
|
+
comments: { nodes: [] },
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function comment(author: Actor, body: string, days: number, extra: Partial<Comment> = {}): Comment {
|
|
54
|
+
comments += 1;
|
|
55
|
+
return { id: `IC_${comments}`, author, body, createdAt: daysAgo(days), url: `https://github.com/${REPO}/issues/1#c${comments}`, reactionGroups: [], ...extra };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mention(number: number, comments: Comment[], overrides: Partial<MentionNode> = {}): MentionNode {
|
|
59
|
+
return {
|
|
60
|
+
__typename: "Issue",
|
|
61
|
+
number,
|
|
62
|
+
title: `Issue ${number}`,
|
|
63
|
+
url: `https://github.com/${REPO}/issues/${number}`,
|
|
64
|
+
createdAt: daysAgo(5),
|
|
65
|
+
author: alice,
|
|
66
|
+
body: "no mention here",
|
|
67
|
+
repository,
|
|
68
|
+
comments: { nodes: comments },
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function issue(number: number, comments: Comment[] = [], title = `Issue ${number}`): IssueNode {
|
|
74
|
+
return { number, title, url: `https://github.com/${REPO}/issues/${number}`, repository, comments: { nodes: comments } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pickable(number: number, fields: Record<string, string>, createdDaysAgo = 10, type = "Bug", title = `Issue ${number}`, repo = REPO): PickableNode {
|
|
78
|
+
return {
|
|
79
|
+
number,
|
|
80
|
+
title,
|
|
81
|
+
url: `https://github.com/${repo}/issues/${number}`,
|
|
82
|
+
createdAt: daysAgo(createdDaysAgo),
|
|
83
|
+
repository: repo === REPO ? repository : { nameWithOwner: repo, defaultBranchRef: { name: "main" } },
|
|
84
|
+
issueType: { name: type },
|
|
85
|
+
issueFieldValues: { nodes: Object.entries(fields).map(([field, name]) => ({ name, field: { name: field } })) },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function search(parts: Partial<{ [K in Exclude<keyof SearchResult, "viewer">]: SearchResult[K]["nodes"] }>): SearchResult {
|
|
90
|
+
const nodes = <K extends keyof typeof parts>(key: K) => ({ nodes: parts[key] ?? [] });
|
|
91
|
+
return {
|
|
92
|
+
viewer: { login: "me", id: "U_me" },
|
|
93
|
+
mine: nodes("mine") as SearchResult["mine"],
|
|
94
|
+
reviews: nodes("reviews") as SearchResult["reviews"],
|
|
95
|
+
mentions: nodes("mentions") as SearchResult["mentions"],
|
|
96
|
+
commented: nodes("commented") as SearchResult["commented"],
|
|
97
|
+
assigned: nodes("assigned") as SearchResult["assigned"],
|
|
98
|
+
unclaimed: nodes("unclaimed") as SearchResult["unclaimed"],
|
|
99
|
+
merged: nodes("merged") as SearchResult["merged"],
|
|
100
|
+
reviewed: nodes("reviewed") as SearchResult["reviewed"],
|
|
101
|
+
closed: nodes("closed") as SearchResult["closed"],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { daysAgo, commit, thread, pr, comment, mention, issue, pickable, search };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One of everything the screen can show.
|
|
109
|
+
export function fixture(now: Date): { data: SearchResult; details: Details } {
|
|
110
|
+
const { daysAgo, commit, thread, pr, comment, mention, issue, pickable, search } = builders(now);
|
|
111
|
+
const key = (n: number) => `${REPO}#${n}`;
|
|
112
|
+
const closes = (number: number, title: string) => ({ nodes: [{ number, title, url: `https://github.com/${REPO}/issues/${number}`, repository }] });
|
|
113
|
+
const fields = (priority: string, effort: string, scope: string) => ({ Priority: priority, Effort: effort, Scope: scope });
|
|
114
|
+
const merged = (number: number, title: string, days: number, closesIssue?: number) => ({
|
|
115
|
+
number,
|
|
116
|
+
title,
|
|
117
|
+
url: `https://github.com/${REPO}/pull/${number}`,
|
|
118
|
+
mergedAt: daysAgo(days),
|
|
119
|
+
repository,
|
|
120
|
+
closingIssuesReferences: { nodes: closesIssue ? [{ number: closesIssue, repository }] : [] },
|
|
121
|
+
});
|
|
122
|
+
const closed = (number: number, title: string, days: number, stateReason = "COMPLETED") => ({
|
|
123
|
+
number,
|
|
124
|
+
title,
|
|
125
|
+
url: `https://github.com/${REPO}/issues/${number}`,
|
|
126
|
+
closedAt: daysAgo(days),
|
|
127
|
+
stateReason,
|
|
128
|
+
repository,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const data = search({
|
|
132
|
+
mine: [
|
|
133
|
+
pr(101, { title: "Show delivery dates on the order page" }, "FAILURE"),
|
|
134
|
+
pr(102, { title: "Retry webhook deliveries with backoff", reviewThreads: { nodes: [thread(alice, 1), thread(bot, 2)] } }),
|
|
135
|
+
pr(103, { title: "Fetch supplier price files nightly", reviewDecision: "APPROVED", closingIssuesReferences: closes(90, "Supplier prices are a week out of date") }),
|
|
136
|
+
pr(104, { title: "Move price-file login into a service", isDraft: true, baseRefName: "branch-103", headRefName: "branch-104" }),
|
|
137
|
+
pr(105, { title: "Dialog focus returns to the trigger", reviewDecision: "APPROVED", headRefName: "a11y-1" }),
|
|
138
|
+
pr(106, { title: "Keyboard navigation for calendar cards", isDraft: true, baseRefName: "a11y-1", headRefName: "a11y-2", reviewDecision: "APPROVED" }),
|
|
139
|
+
pr(107, { title: "Calendar: jump between weeks with keys", isDraft: true, baseRefName: "a11y-2", headRefName: "a11y-3" }, "FAILURE"),
|
|
140
|
+
pr(108, { title: "Company-scoped database layer", mergeable: "CONFLICTING" }),
|
|
141
|
+
pr(109, { title: "Store uploads locally in development", commits: commit("SUCCESS", 6) }),
|
|
142
|
+
pr(110, { title: "requirements.yaml for the shop", isDraft: true, updatedAt: daysAgo(64) }),
|
|
143
|
+
pr(111, { title: "Faster product search", reviewRequests: { nodes: [{ requestedReviewer: bob }] }, commits: commit("SUCCESS", 0.3) }),
|
|
144
|
+
pr(112, { title: "Translate the checkout emails", reviewDecision: "CHANGES_REQUESTED", latestReviews: { nodes: [{ author: alice, state: "CHANGES_REQUESTED", submittedAt: daysAgo(2) }] }, commits: commit("SUCCESS", 1) }),
|
|
145
|
+
pr(113, { title: "Upgrade the payment SDK" }, "PENDING"),
|
|
146
|
+
pr(114, { title: "Cache exchange rates daily", reviewDecision: "APPROVED" }),
|
|
147
|
+
pr(115, { title: "Split the settings page into tabs", isDraft: true, mergeable: "CONFLICTING" }),
|
|
148
|
+
pr(116, { title: "Try a faster PDF renderer", isDraft: true }, "FAILURE"),
|
|
149
|
+
pr(117, { title: "Harden the upload endpoint", reviewThreads: { nodes: [thread(bot, 1)] } }),
|
|
150
|
+
pr(118, { title: "Send reminders for unpaid invoices", closingIssuesReferences: closes(95, "Customers forget unpaid invoices") }),
|
|
151
|
+
pr(119, { title: "Show stock levels on product cards", isDraft: true }),
|
|
152
|
+
pr(122, { title: "Round VAT per line, not per invoice", reviewDecision: "APPROVED", repository: { ...repository, autoMergeAllowed: true } }, "PENDING"),
|
|
153
|
+
pr(123, { title: "Archive offers older than a year", reviewDecision: "APPROVED", autoMergeRequest: { enabledAt: daysAgo(0.1) } }, "PENDING"),
|
|
154
|
+
],
|
|
155
|
+
reviews: [pr(120, { title: "Add a status page", author: bob }), pr(121, { title: "Rework the cart totals", author: alice })],
|
|
156
|
+
mentions: [
|
|
157
|
+
mention(95, [comment(bob, "@me should reminders go out on weekends too, or only on workdays?", 0.5)], { title: "Customers forget unpaid invoices", author: bob }),
|
|
158
|
+
mention(121, [comment(alice, "@me could you look at the rounding in `totals.ts` first? That's the **risky** part.", 0.2)], { __typename: "PullRequest", title: "Rework the cart totals" }),
|
|
159
|
+
mention(
|
|
160
|
+
130,
|
|
161
|
+
[
|
|
162
|
+
comment(alice, "Can we make it easy to get in touch? @me do you see anything harmful here?", 3),
|
|
163
|
+
comment(
|
|
164
|
+
bob,
|
|
165
|
+
"@me also, should the contact be the shared `security@` inbox or a person? I'd rather not put a personal address in a public file.\n\n- shared inbox: nobody owns it\n- a person: leaves with them\n\nSee [RFC 9116](https://www.rfc-editor.org/rfc/rfc9116).",
|
|
166
|
+
2,
|
|
167
|
+
),
|
|
168
|
+
],
|
|
169
|
+
{ __typename: "PullRequest", title: "Publish security.txt" },
|
|
170
|
+
),
|
|
171
|
+
mention(131, [], {
|
|
172
|
+
__typename: "PullRequest",
|
|
173
|
+
title: "Close two privilege escalations",
|
|
174
|
+
reviewThreads: { nodes: [{ isResolved: false, comments: { nodes: [comment(bot, "@me this endpoint leaks the API key in its response", 1)] } }] },
|
|
175
|
+
}),
|
|
176
|
+
],
|
|
177
|
+
commented: [issue(140, [comment(alice, "", 9), comment(me, "Which customers see this?", 1)], "Invoice totals round wrong"), issue(141, [comment(me, "Is this still needed?", 12)], "Rotate the analytics token")],
|
|
178
|
+
assigned: [
|
|
179
|
+
pickable(150, fields("🏔 P1 High", "🐇 Small (0.5-2d)", "🔙 Backend"), 20, "Bug", "Orders export times out for big shops"),
|
|
180
|
+
pickable(151, fields("🏕P2 Medium", "🐟 Tiny (< 4h)", "🔙 Backend"), 5, "Bug", "Price list import skips quoted rows", ISSUES_REPO),
|
|
181
|
+
],
|
|
182
|
+
unclaimed: [
|
|
183
|
+
pickable(160, fields("🏕P2 Medium", "🐂 Medium (2-5d)", "🕵 Full-stack"), 40, "Feature", "Follow-up emails for unanswered offers"),
|
|
184
|
+
pickable(161, fields("🏝 P3 Low", "🐟 Tiny (< 4h)", "🔙 Backend"), 15, "Bug", "Deleted employee still shows in the calendar"),
|
|
185
|
+
pickable(162, fields("🏝 P3 Low", "🐇 Small (0.5-2d)", "🔙 Backend"), 30, "Debt", "Merge the two import commands"),
|
|
186
|
+
pickable(163, fields("🏝 P3 Low", "🐇 Small (0.5-2d)", "🖌 Frontend"), 8, "Bug", "Moving a task in the calendar loses its colour"),
|
|
187
|
+
pickable(164, fields("🏝 P3 Low", "🦑 Large (1-2w)", "📱 Mobile app"), 60, "Feature", "Offline mode for time tracking"),
|
|
188
|
+
pickable(165, {}, 3, "Task", "Devcontainers for pull requests"),
|
|
189
|
+
],
|
|
190
|
+
merged: [merged(170, "Nightly backup check", 1, 171), merged(172, "Fix VAT on credit notes", 3), merged(173, "Speed up the order list", 9, 174), merged(175, "Remove the old importer", 16)],
|
|
191
|
+
reviewed: [
|
|
192
|
+
{ number: 180, title: "New onboarding checklist", url: `https://github.com/${REPO}/pull/180`, mergedAt: daysAgo(2), repository, author: alice },
|
|
193
|
+
{ number: 181, title: "Bump dependencies", url: `https://github.com/${REPO}/pull/181`, mergedAt: daysAgo(4), repository, author: bob },
|
|
194
|
+
{ number: 182, title: "Sentry release tracking", url: `https://github.com/${REPO}/pull/182`, mergedAt: daysAgo(11), repository, author: alice },
|
|
195
|
+
],
|
|
196
|
+
closed: [closed(171, "Backups are never verified", 1), closed(174, "Order list takes 8 seconds", 9), closed(176, "Dark mode for the admin", 10, "NOT_PLANNED")],
|
|
197
|
+
});
|
|
198
|
+
return { data, details: { behind: { [key(103)]: 1, [key(106)]: 12, [key(108)]: 139, [key(110)]: 2307, [key(114)]: 3, [key(115)]: 40 }, fieldOptions: { Priority: ["🌋 P0 Urgent", "🏔 P1 High", "🏕P2 Medium", "🏝 P3 Low"] } } };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Sessions the preview shows as started from items: working, needs me, failed, idle.
|
|
202
|
+
const agent = (id: string, status: string, requiresAttention = false): AgentSummary => ({ id, status, requiresAttention, title: `Fixture session ${id}`, workspaceId: null });
|
|
203
|
+
export const FIXTURE_AGENTS: Record<string, AgentSummary> = {
|
|
204
|
+
[`${REPO}#101`]: agent("agent-101", "running"),
|
|
205
|
+
[`${REPO}#102`]: agent("agent-102", "idle", true),
|
|
206
|
+
[`${REPO}#108`]: agent("agent-108", "error"),
|
|
207
|
+
[`${REPO}#150`]: agent("agent-150", "idle"),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const FIXTURE_CONTEXT = "Context from GitHub would go here (fixture mode).";
|
|
211
|
+
|
|
212
|
+
// What a sheet shows, made up: a size, the linked issue, and failing checks for Rerun.
|
|
213
|
+
export function fixtureDetails(subject: { kind: "pr" | "issue"; repo: string; number: number }, target: Target | undefined, checks: boolean): ItemDetails {
|
|
214
|
+
const pr = target?.kind === "pr" ? target : undefined;
|
|
215
|
+
return {
|
|
216
|
+
size: pr ? { additions: 84 + (pr.number % 7) * 13, deletions: 12 + (pr.number % 5), files: 2 + (pr.number % 4) } : undefined,
|
|
217
|
+
linked: subject.number === 120 ? [{ number: 200, title: "Customers can't tell when the shop is down", url: `https://github.com/${REPO}/issues/200`, state: "OPEN" }] : [],
|
|
218
|
+
failing: checks && pr ? [{ pr: pr.number, checks: [{ name: "lint", rerunnable: true }, { name: "e2e (chromium)", rerunnable: true }], runs: [{ id: 9001, attempt: 1 }] }] : [],
|
|
219
|
+
};
|
|
220
|
+
}
|
package/server/gh.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Everything goes through the host's existing gh login; no token is stored [SEC-01].
|
|
4
|
+
|
|
5
|
+
// Previews run on made-up data and never reach GitHub.
|
|
6
|
+
export const FIXTURE = process.env.PASEO_ATTENTION_SPAN_FIXTURE === "1";
|
|
7
|
+
|
|
8
|
+
export function runGh(args: string[], input?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
9
|
+
if (FIXTURE) return Promise.reject(new Error("GitHub is off in fixture mode."));
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const child = spawn("gh", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
12
|
+
let stdout = "";
|
|
13
|
+
let stderr = "";
|
|
14
|
+
child.stdout.on("data", (chunk) => (stdout += chunk));
|
|
15
|
+
child.stderr.on("data", (chunk) => (stderr += chunk));
|
|
16
|
+
child.on("error", (error: NodeJS.ErrnoException) =>
|
|
17
|
+
reject(error.code === "ENOENT" ? new Error("The GitHub CLI (gh) isn't on the daemon's PATH.") : error),
|
|
18
|
+
);
|
|
19
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
20
|
+
child.stdin.end(input ?? "");
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Partial errors (e.g. a deleted branch in a compare) still return the data that resolved.
|
|
25
|
+
export async function graphql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
|
|
26
|
+
const { code, stdout, stderr } = await runGh(["api", "graphql", "--input", "-"], JSON.stringify({ query, variables }));
|
|
27
|
+
let parsed: { data?: T; errors?: { message: string }[] } | undefined;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(stdout);
|
|
30
|
+
} catch {
|
|
31
|
+
// Not JSON: an auth or network failure.
|
|
32
|
+
}
|
|
33
|
+
if (parsed?.data) return parsed.data;
|
|
34
|
+
const message = parsed?.errors?.map((error) => error.message).join("; ") || stderr.trim() || `gh exited with ${code}`;
|
|
35
|
+
throw new Error(message);
|
|
36
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Shapes returned by the queries in queries.ts.
|
|
2
|
+
|
|
3
|
+
type Nodes<T> = { nodes: T[] };
|
|
4
|
+
type Repo = { nameWithOwner: string; defaultBranchRef?: { name: string } | null };
|
|
5
|
+
|
|
6
|
+
export interface Actor {
|
|
7
|
+
__typename?: string; // "User", "Bot", ...
|
|
8
|
+
login: string;
|
|
9
|
+
avatarUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Comment {
|
|
13
|
+
id?: string;
|
|
14
|
+
author: Actor | null;
|
|
15
|
+
body?: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
url?: string;
|
|
18
|
+
reactionGroups?: { viewerHasReacted: boolean }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReviewThread {
|
|
22
|
+
isResolved: boolean;
|
|
23
|
+
opener?: Nodes<Comment>;
|
|
24
|
+
comments: Nodes<Comment>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PullRequestNode {
|
|
28
|
+
id: string;
|
|
29
|
+
number: number;
|
|
30
|
+
title: string;
|
|
31
|
+
url: string;
|
|
32
|
+
isDraft: boolean;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
updatedAt: string;
|
|
35
|
+
author: Actor | null;
|
|
36
|
+
repository: Repo & { squashMergeAllowed?: boolean; mergeCommitAllowed?: boolean; autoMergeAllowed?: boolean };
|
|
37
|
+
baseRefName: string;
|
|
38
|
+
headRefName: string;
|
|
39
|
+
headRefOid: string;
|
|
40
|
+
isCrossRepository?: boolean;
|
|
41
|
+
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
42
|
+
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
|
43
|
+
autoMergeRequest?: { enabledAt: string } | null;
|
|
44
|
+
assignees: Nodes<{ login: string }>;
|
|
45
|
+
closingIssuesReferences: Nodes<{ number: number; title: string; url: string; repository: Repo }>;
|
|
46
|
+
commits: Nodes<{ commit: { committedDate: string; statusCheckRollup: { state: string } | null } }>;
|
|
47
|
+
reviewRequests: Nodes<{ requestedReviewer: Actor | null }>;
|
|
48
|
+
latestReviews: Nodes<{ author: Actor | null; state: string; submittedAt: string | null }>;
|
|
49
|
+
reviewThreads: Nodes<ReviewThread>;
|
|
50
|
+
comments: Nodes<Comment>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A search hit from mentions:@me, with bodies for finding the mention.
|
|
54
|
+
export interface MentionNode {
|
|
55
|
+
__typename: "Issue" | "PullRequest";
|
|
56
|
+
id?: string;
|
|
57
|
+
reactionGroups?: { viewerHasReacted: boolean }[];
|
|
58
|
+
number: number;
|
|
59
|
+
title: string;
|
|
60
|
+
url: string;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
author: Actor | null;
|
|
63
|
+
body: string;
|
|
64
|
+
repository: Repo;
|
|
65
|
+
comments: Nodes<Comment>;
|
|
66
|
+
reviews?: Nodes<{ author: Actor | null; submittedAt: string | null }>;
|
|
67
|
+
reviewThreads?: Nodes<ReviewThread>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IssueNode {
|
|
71
|
+
number: number;
|
|
72
|
+
title: string;
|
|
73
|
+
url: string;
|
|
74
|
+
repository: Repo;
|
|
75
|
+
comments: Nodes<Comment>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PickableNode {
|
|
79
|
+
number: number;
|
|
80
|
+
title: string;
|
|
81
|
+
url: string;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
repository: Repo;
|
|
84
|
+
issueType: { name: string } | null;
|
|
85
|
+
issueFieldValues: Nodes<{ name?: string; field?: { name: string } }>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One request per search, merged [GH-01].
|
|
89
|
+
export interface LiveResult {
|
|
90
|
+
viewer: { login: string; id: string };
|
|
91
|
+
mine: Nodes<PullRequestNode>;
|
|
92
|
+
reviews: Nodes<PullRequestNode>;
|
|
93
|
+
mentions: Nodes<MentionNode>;
|
|
94
|
+
commented: Nodes<IssueNode>;
|
|
95
|
+
assigned: Nodes<PickableNode>;
|
|
96
|
+
unclaimed: Nodes<PickableNode>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// One request for all three [HIS-01, HIS-02].
|
|
100
|
+
export interface HistoryResult {
|
|
101
|
+
merged: Nodes<{ number: number; title: string; url: string; mergedAt: string; repository: Repo; closingIssuesReferences: Nodes<{ number: number; repository: Repo }> }>;
|
|
102
|
+
reviewed: Nodes<{ number: number; title: string; url: string; mergedAt: string; repository: Repo; author: Actor | null }>;
|
|
103
|
+
closed: Nodes<{ number: number; title: string; url: string; closedAt: string; stateReason: string | null; repository: Repo }>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type SearchResult = LiveResult & HistoryResult;
|
|
107
|
+
|
|
108
|
+
// From the request after my PRs and Next up's issues.
|
|
109
|
+
export interface Details {
|
|
110
|
+
// "owner/name#number" -> commits the head is behind its base.
|
|
111
|
+
behind: Record<string, number>;
|
|
112
|
+
// Issue field name -> its options in order.
|
|
113
|
+
fieldOptions: Record<string, string[]>;
|
|
114
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// GitHub Markdown for the screen: whole comments to render in the 👍 picker, and plain text for quotes cut anywhere.
|
|
2
|
+
|
|
3
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
4
|
+
|
|
5
|
+
// Markdown cut after whole blocks only, so a cut never breaks a link or code block [MEN-07].
|
|
6
|
+
// HTML comments (PR templates) and images go; other HTML tags, like <details>, keep only their text.
|
|
7
|
+
export function excerpt(body: string | undefined, max = 600): string | null {
|
|
8
|
+
const blocks = markdownBlocks(
|
|
9
|
+
(body ?? "")
|
|
10
|
+
.replace(/\r/g, "")
|
|
11
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
12
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ""),
|
|
13
|
+
);
|
|
14
|
+
if (blocks.length === 0) return null;
|
|
15
|
+
const kept: string[] = [];
|
|
16
|
+
let length = 0;
|
|
17
|
+
for (const block of blocks) {
|
|
18
|
+
if (length + block.length > max) return kept.length ? `${kept.join("\n\n")}\n\n…` : cutBlock(block, max);
|
|
19
|
+
kept.push(block);
|
|
20
|
+
length += block.length;
|
|
21
|
+
}
|
|
22
|
+
return kept.join("\n\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Paragraphs, lists and code blocks, split at blank lines outside code; HTML tags stripped outside code.
|
|
26
|
+
function markdownBlocks(text: string): string[] {
|
|
27
|
+
const blocks: string[] = [];
|
|
28
|
+
let lines: string[] = [];
|
|
29
|
+
let inCode = false;
|
|
30
|
+
const end = () => {
|
|
31
|
+
const block = lines.join("\n").trim();
|
|
32
|
+
if (block) blocks.push(block);
|
|
33
|
+
lines = [];
|
|
34
|
+
};
|
|
35
|
+
for (const line of text.split("\n")) {
|
|
36
|
+
if (FENCE.test(line)) inCode = !inCode;
|
|
37
|
+
else if (!inCode && !line.trim()) {
|
|
38
|
+
end();
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
lines.push(inCode || FENCE.test(line) ? line : line.replace(/<\/?[a-zA-Z][^>]*>/g, ""));
|
|
42
|
+
}
|
|
43
|
+
end();
|
|
44
|
+
return blocks;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A first block too long on its own: cut at a line, else a word, and close an open code block.
|
|
48
|
+
function cutBlock(block: string, max: number): string {
|
|
49
|
+
const cut = block.slice(0, max);
|
|
50
|
+
const at = cut.lastIndexOf("\n") > max / 2 ? cut.lastIndexOf("\n") : Math.max(cut.lastIndexOf(" "), max - 40);
|
|
51
|
+
const kept = cut.slice(0, at).trimEnd();
|
|
52
|
+
return FENCE.test(kept) ? `${kept}\n\`\`\`\n\n…` : `${kept}…`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Markdown as plain text, for quotes cut mid-sentence where rendering would break [ATT-05].
|
|
56
|
+
// Links keep their text, emphasis and code lose their marks, quoted replies and HTML go.
|
|
57
|
+
export function plainText(markdown: string): string {
|
|
58
|
+
return markdown
|
|
59
|
+
.replace(/\r/g, "")
|
|
60
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
61
|
+
.replace(/^[ \t]*>.*$/gm, "")
|
|
62
|
+
.replace(/^[ \t]*(```|~~~).*$/gm, "")
|
|
63
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
|
64
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
65
|
+
.replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
|
|
66
|
+
.replace(/<\/?[a-zA-Z][^>]*>/g, "")
|
|
67
|
+
.replace(/`([^`]*)`/g, "$1")
|
|
68
|
+
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
|
|
69
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1")
|
|
70
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2")
|
|
71
|
+
.replace(/^[ \t]*#{1,6}[ \t]+/gm, "")
|
|
72
|
+
.replace(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/gm, "");
|
|
73
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { UnclaimedSource } from "../shared/attention";
|
|
2
|
+
import type { AttentionConfig } from "../shared/config";
|
|
3
|
+
import type { PullRequestNode } from "./github-types";
|
|
4
|
+
|
|
5
|
+
// Each search with how many results it asks for and what it reads of each.
|
|
6
|
+
const SEARCHES = {
|
|
7
|
+
mine: { first: 50, nodes: "...pr" },
|
|
8
|
+
reviews: { first: 30, nodes: "...pr" },
|
|
9
|
+
mentions: {
|
|
10
|
+
first: 30,
|
|
11
|
+
nodes: `__typename
|
|
12
|
+
... on Issue { id number title url createdAt body author { ...author } reactionGroups { viewerHasReacted } repository { nameWithOwner } comments(last: 30) { nodes { ...comment } } }
|
|
13
|
+
... on PullRequest {
|
|
14
|
+
id number title url createdAt body author { ...author } reactionGroups { viewerHasReacted } repository { nameWithOwner } comments(last: 30) { nodes { ...comment } }
|
|
15
|
+
reviews(last: 20) { nodes { author { login } submittedAt } }
|
|
16
|
+
reviewThreads(last: 30) { nodes { isResolved comments(first: 20) { nodes { ...comment } } } }
|
|
17
|
+
}`,
|
|
18
|
+
},
|
|
19
|
+
commented: { first: 30, nodes: "... on Issue { number title url repository { nameWithOwner } comments(last: 1) { nodes { author { login } createdAt } } }" },
|
|
20
|
+
assigned: { first: 50, nodes: "...pickable" },
|
|
21
|
+
unclaimed: { first: 100, nodes: "...pickable" },
|
|
22
|
+
merged: {
|
|
23
|
+
first: 100,
|
|
24
|
+
nodes: "... on PullRequest { number title url mergedAt repository { nameWithOwner } closingIssuesReferences(first: 3) { nodes { number repository { nameWithOwner } } } }",
|
|
25
|
+
},
|
|
26
|
+
reviewed: { first: 100, nodes: "... on PullRequest { number title url mergedAt repository { nameWithOwner } author { ...author } }" },
|
|
27
|
+
closed: { first: 100, nodes: "... on Issue { number title url closedAt stateReason repository { nameWithOwner } }" },
|
|
28
|
+
};
|
|
29
|
+
export type SearchName = keyof typeof SEARCHES;
|
|
30
|
+
|
|
31
|
+
// Doing, Waiting and Next up. History has its own, slower schedule.
|
|
32
|
+
export const LIVE_SEARCHES = ["mine", "reviews", "mentions", "commented", "assigned", "unclaimed"] as const;
|
|
33
|
+
export const HISTORY_SEARCHES = ["merged", "reviewed", "closed"] as const;
|
|
34
|
+
|
|
35
|
+
const FRAGMENTS: Record<string, string> = {
|
|
36
|
+
author: "fragment author on Actor { __typename login avatarUrl(size: 64) }",
|
|
37
|
+
comment: `fragment comment on Comment {
|
|
38
|
+
body createdAt
|
|
39
|
+
author { ...author }
|
|
40
|
+
... on IssueComment { id url reactionGroups { viewerHasReacted } }
|
|
41
|
+
... on PullRequestReviewComment { id url reactionGroups { viewerHasReacted } }
|
|
42
|
+
}`,
|
|
43
|
+
pickable: `fragment pickable on Issue {
|
|
44
|
+
number title url createdAt
|
|
45
|
+
repository { nameWithOwner defaultBranchRef { name } }
|
|
46
|
+
issueType { name }
|
|
47
|
+
issueFieldValues(first: 20) { nodes { ... on IssueFieldSingleSelectValue { name field { ... on IssueFieldCommon { name } } } } }
|
|
48
|
+
}`,
|
|
49
|
+
pr: `fragment pr on PullRequest {
|
|
50
|
+
id number title url isDraft createdAt updatedAt
|
|
51
|
+
author { ...author }
|
|
52
|
+
repository { nameWithOwner squashMergeAllowed mergeCommitAllowed autoMergeAllowed defaultBranchRef { name } }
|
|
53
|
+
baseRefName headRefName headRefOid isCrossRepository mergeable reviewDecision
|
|
54
|
+
autoMergeRequest { enabledAt }
|
|
55
|
+
assignees(first: 10) { nodes { login } }
|
|
56
|
+
closingIssuesReferences(first: 5) { nodes { number title url repository { nameWithOwner } } }
|
|
57
|
+
commits(last: 1) { nodes { commit { committedDate statusCheckRollup { state } } } }
|
|
58
|
+
reviewRequests(first: 5) { nodes { requestedReviewer { ... on User { ...author } } } }
|
|
59
|
+
latestReviews(first: 10) { nodes { author { ...author } state submittedAt } }
|
|
60
|
+
reviewThreads(last: 30) {
|
|
61
|
+
nodes {
|
|
62
|
+
isResolved
|
|
63
|
+
opener: comments(first: 1) { nodes { author { __typename login } } }
|
|
64
|
+
comments(last: 1) { nodes { author { login } createdAt } }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
comments(last: 10) { nodes { author { login } createdAt } }
|
|
68
|
+
}`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// A query for some searches, as aliases named after them, with only the fragments they use; GitHub rejects unused ones.
|
|
72
|
+
// GitHub runs the searches in one request one after another, so the live ones go out as separate requests in parallel [GH-01].
|
|
73
|
+
export function searchQuery(names: readonly SearchName[], withViewer = false): string {
|
|
74
|
+
const fields = names.map((name) => ` ${name}: search(type: ISSUE, query: $${name}, first: ${SEARCHES[name].first}) {\n nodes { ${SEARCHES[name].nodes} }\n }`);
|
|
75
|
+
const used = new Set<string>();
|
|
76
|
+
const collect = (text: string) => {
|
|
77
|
+
for (const [, name] of text.matchAll(/\.\.\.(\w+)/g)) {
|
|
78
|
+
if (used.has(name)) continue;
|
|
79
|
+
used.add(name);
|
|
80
|
+
collect(FRAGMENTS[name]);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
fields.forEach(collect);
|
|
84
|
+
const variables = names.map((name) => `$${name}: String!`).join(", ");
|
|
85
|
+
return `query Attention(${variables}) {\n${withViewer ? " viewer { login id }\n" : ""}${fields.join("\n")}\n}\n${[...used].map((name) => FRAGMENTS[name]).join("\n")}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function where(orgs: string[], repos: string[]): string {
|
|
89
|
+
return [...orgs.map((org) => `org:${org}`), ...repos.map((repo) => `repo:${repo}`), "archived:false"].join(" ");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const isoDate = (date: Date) => date.toISOString().slice(0, 10);
|
|
93
|
+
|
|
94
|
+
function startOfWeek(now: Date): Date {
|
|
95
|
+
const monday = new Date(now);
|
|
96
|
+
monday.setUTCHours(0, 0, 0, 0);
|
|
97
|
+
monday.setUTCDate(monday.getUTCDate() - ((monday.getUTCDay() + 6) % 7));
|
|
98
|
+
return monday;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Where Next up looks for unclaimed issues: the chosen repos, else the configured orgs and repos, else my Paseo
|
|
102
|
+
// projects' repos [NXT-04, NXT-08]. Never all of GitHub: unlike the other searches, this one isn't about me.
|
|
103
|
+
export function unclaimedScope(config: AttentionConfig, projectRepos: string[]): { from: UnclaimedSource; scope: string | null } {
|
|
104
|
+
if (config.nextUp.repos) return { from: "config", scope: config.nextUp.repos.length ? where([], config.nextUp.repos) : null };
|
|
105
|
+
if (config.orgs.length || config.repos.length) return { from: "config", scope: where(config.orgs, config.repos) };
|
|
106
|
+
return projectRepos.length ? { from: "projects", scope: where([], projectRepos) } : { from: "none", scope: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Null for a search with nowhere to look.
|
|
110
|
+
export function searchVariables(config: AttentionConfig, now: Date, projectRepos: string[] = []): Record<SearchName, string | null> {
|
|
111
|
+
const scope = where(config.orgs, config.repos);
|
|
112
|
+
const unclaimed = unclaimedScope(config, projectRepos).scope;
|
|
113
|
+
const since = isoDate(new Date(now.getTime() - config.mentionDays * 86_400_000));
|
|
114
|
+
// Whole weeks, from a Monday, so the oldest week isn't cut short.
|
|
115
|
+
const history = isoDate(new Date(startOfWeek(now).getTime() - (config.historyWeeks - 1) * 7 * 86_400_000));
|
|
116
|
+
return {
|
|
117
|
+
mine: `is:pr is:open author:@me ${scope}`,
|
|
118
|
+
// By name only, not via a team [REV-01].
|
|
119
|
+
reviews: `is:pr is:open user-review-requested:@me ${scope}`,
|
|
120
|
+
mentions: `is:open mentions:@me updated:>=${since} ${scope}`,
|
|
121
|
+
assigned: `is:issue is:open assignee:@me ${scope}`,
|
|
122
|
+
commented: `is:issue is:open commenter:@me updated:>=${since} ${scope}`,
|
|
123
|
+
// [NXT-01, NXT-04, NXT-08]
|
|
124
|
+
unclaimed: unclaimed && `is:issue is:open no:assignee ${unclaimed}`,
|
|
125
|
+
// History [HIS-01, HIS-02]
|
|
126
|
+
merged: `is:pr is:merged author:@me merged:>=${history} ${scope}`,
|
|
127
|
+
reviewed: `is:pr is:merged reviewed-by:@me -author:@me merged:>=${history} ${scope}`,
|
|
128
|
+
closed: `is:issue is:closed assignee:@me closed:>=${history} ${scope}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function prKey(node: { repository: { nameWithOwner: string }; number: number }): string {
|
|
133
|
+
return `${node.repository.nameWithOwner}#${node.number}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// After my PRs and Next up's issues: how far my PRs are behind their bases, and the option order of org issue fields [GH-01].
|
|
137
|
+
export function detailsQuery(prs: PullRequestNode[], orgs: string[]): { query: string; compares: Record<string, string> } {
|
|
138
|
+
const compares: Record<string, string> = {};
|
|
139
|
+
const fields = prs.map((pr, index) => {
|
|
140
|
+
compares[`c${index}`] = prKey(pr);
|
|
141
|
+
const [owner, name] = pr.repository.nameWithOwner.split("/");
|
|
142
|
+
return `c${index}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {
|
|
143
|
+
ref(qualifiedName: ${JSON.stringify(`refs/heads/${pr.baseRefName}`)}) { compare(headRef: ${JSON.stringify(pr.headRefName)}) { behindBy } }
|
|
144
|
+
}`;
|
|
145
|
+
});
|
|
146
|
+
orgs.forEach((org, index) =>
|
|
147
|
+
fields.push(`o${index}: organization(login: ${JSON.stringify(org)}) {
|
|
148
|
+
issueFields(first: 50) { nodes { ... on IssueFieldSingleSelect { name options { name } } } }
|
|
149
|
+
}`),
|
|
150
|
+
);
|
|
151
|
+
return { query: `query Details {\n${fields.join("\n")}\n}`, compares };
|
|
152
|
+
}
|