f0b06dea Andy McCall 1 month ago
1
// The Proposals view (spec §5): per repository with a hub remote, three
2
// buckets, a detail webview, and the actions.
3
4
import * as vscode from "vscode";
5
import type * as api from "./hubapi";
6
import { HubAuth } from "./auth";
7
import { hubUrl, needsMe, parseRepoUrl } from "./hub";
8
import type { OxygenAPI, RepositoryHandle } from "./oxygenapi";
9
10
/** A repository in the workspace with a hub remote. */
11
export interface HubRepo {
12
handle: RepositoryHandle;
13
base: string;
14
owner: string;
15
name: string;
16
remote: string;
17
}
18
bf73998c Andy McCall 1 month ago ↶
19
/** Just enough to name a repository on a hub. */
20
export type RepoRef = Pick<HubRepo, "base" | "owner" | "name">;
21
f0b06dea Andy McCall 1 month ago
22
/** Find the hub remotes of every repository the Oxygen extension knows. */
23
export async function hubRepos(oxygen: OxygenAPI, auth: HubAuth): Promise<HubRepo[]> {
24
const out: HubRepo[] = [];
25
const bases = auth.bases();
26
for (const handle of oxygen.repositories) {
27
let remotes: { name: string; url: string }[] = [];
28
try {
29
remotes = await handle.remotes();
30
} catch {
31
continue;
32
}
33
for (const r of remotes) {
34
const p = parseRepoUrl(r.url);
35
if (p && bases.includes(p.base)) {
36
out.push({ handle, base: p.base, owner: p.owner, name: p.name, remote: r.name });
37
break;
38
}
39
}
40
}
41
return out;
42
}
43
bf73998c Andy McCall 1 month ago ↶
44
type Node =
45
| { kind: "repo"; repo: HubRepo }
46
| { kind: "bucket"; repo: HubRepo; label: string; items: api.Proposal[] }
47
| { kind: "proposal"; repo: HubRepo; p: api.Proposal }
48
| { kind: "file"; repo: HubRepo; p: api.Proposal; f: api.ProposalFile };
49
50
const FILE_ICONS: Record<string, string> = { added: "diff-added", removed: "diff-removed", renamed: "diff-renamed", modified: "diff-modified" };
f0b06dea Andy McCall 1 month ago
51
52
export class ProposalsProvider implements vscode.TreeDataProvider<Node> {
53
private readonly emitter = new vscode.EventEmitter<void>();
54
readonly onDidChangeTreeData = this.emitter.event;
55
private cache = new Map<string, api.Proposal[]>();
bf73998c Andy McCall 1 month ago ↶
56
private files = new Map<string, api.ProposalFile[]>();
f0b06dea Andy McCall 1 month ago
57
58
constructor(
59
private readonly oxygen: () => OxygenAPI | undefined,
60
private readonly auth: HubAuth,
61
) {}
62
63
refresh(): void {
64
this.cache.clear();
bf73998c Andy McCall 1 month ago ↶
65
this.files.clear();
f0b06dea Andy McCall 1 month ago
66
this.emitter.fire();
67
}
68
69
async repos(): Promise<HubRepo[]> {
70
const o = this.oxygen();
71
return o ? hubRepos(o, this.auth) : [];
72
}
73
74
async proposalsOf(repo: HubRepo): Promise<api.Proposal[]> {
75
const key = `${repo.base}/${repo.owner}/${repo.name}`;
76
const hit = this.cache.get(key);
77
if (hit) {
78
return hit;
79
}
80
const list = await this.auth.clientFor(repo.base).proposals(repo.owner, repo.name);
81
this.cache.set(key, list);
82
return list;
83
}
84
bf73998c Andy McCall 1 month ago ↶
85
/** The files a proposal changes, cached until the next refresh. */
86
async filesOf(repo: HubRepo, p: api.Proposal): Promise<api.ProposalFile[]> {
87
const key = `${repo.base}/${repo.owner}/${repo.name}#${p.number}`;
88
const hit = this.files.get(key);
89
if (hit) {
90
return hit;
91
}
92
const list = await this.auth.clientFor(repo.base).proposalFiles(repo.owner, repo.name, p.number);
93
this.files.set(key, list);
94
return list;
95
}
96
f0b06dea Andy McCall 1 month ago
97
getTreeItem(node: Node): vscode.TreeItem {
98
switch (node.kind) {
99
case "repo": {
100
const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded);
101
item.iconPath = new vscode.ThemeIcon("repo");
102
item.contextValue = "repo";
103
return item;
104
}
105
case "bucket": {
106
const item = new vscode.TreeItem(`${node.label} (${node.items.length})`, node.items.length ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed);
107
item.contextValue = "bucket";
108
return item;
109
}
110
case "proposal": {
111
const p = node.p;
bf73998c Andy McCall 1 month ago ↶
112
const item = new vscode.TreeItem(`#${p.number} ${p.title}`, vscode.TreeItemCollapsibleState.Collapsed);
f0b06dea Andy McCall 1 month ago
113
const sealed = p.approvals.filter((a) => a.sealed).length;
114
const chips = [p.state === "draft" ? "draft" : "", p.conflicted ? "conflicts" : "", p.landable ? "landable" : "", sealed ? `${sealed} sealed` : ""].filter(Boolean).join(" · ");
115
item.description = `${p.author} → ${p.target}${chips ? " · " + chips : ""}`;
116
item.tooltip = new vscode.MarkdownString(`**${p.title}** by ${p.author}\n\n${p.changes.length} change(s) into ${p.target}\n\n${p.blockers.length ? "Blocked: " + p.blockers.join("; ") : "Ready to land"}`);
117
item.iconPath = new vscode.ThemeIcon(p.conflicted ? "warning" : p.landable ? "pass" : p.state === "draft" ? "circle-outline" : "git-pull-request");
118
item.contextValue = "proposal";
119
item.command = { title: "Open", command: "oxygenhub.openProposal", arguments: [node] };
120
return item;
121
}
bf73998c Andy McCall 1 month ago ↶
122
case "file": {
123
const f = node.f;
124
const slash = f.path.lastIndexOf("/");
125
const item = new vscode.TreeItem(f.path.slice(slash + 1), vscode.TreeItemCollapsibleState.None);
126
const dir = slash > 0 ? f.path.slice(0, slash) : "";
127
item.description = [dir, f.from ? `from ${f.from}` : "", f.added || f.removed ? `+${f.added} −${f.removed}` : f.kind].filter(Boolean).join(" · ");
128
item.tooltip = `${f.path} (${f.kind})`;
129
item.iconPath = new vscode.ThemeIcon(FILE_ICONS[f.kind] ?? "diff");
130
item.contextValue = "proposalFile";
131
item.command = { title: "Open Diff", command: "oxygenhub.openProposalFile", arguments: [node] };
132
return item;
133
}
f0b06dea Andy McCall 1 month ago
134
}
135
}
136
137
async getChildren(node?: Node): Promise<Node[]> {
138
if (!node) {
139
const repos = await this.repos();
140
return repos.map((repo) => ({ kind: "repo", repo }));
141
}
142
if (node.kind === "repo") {
143
let list: api.Proposal[] = [];
144
try {
145
list = await this.proposalsOf(node.repo);
146
} catch (e) {
092b8bf9 Andy McCall 1 month ago ↶
147
void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`);
f0b06dea Andy McCall 1 month ago
148
return [];
149
}
150
const me = this.auth.sessionFor(node.repo.base)?.username;
151
const mine = list.filter((p) => p.author === me);
152
const needing = list.filter((p) => needsMe(p, me));
153
const rest = list.filter((p) => !mine.includes(p) && !needing.includes(p));
154
return [
155
{ kind: "bucket", repo: node.repo, label: "Needing me", items: needing },
156
{ kind: "bucket", repo: node.repo, label: "Mine", items: mine },
157
{ kind: "bucket", repo: node.repo, label: "Open", items: rest },
158
];
159
}
160
if (node.kind === "bucket") {
161
return node.items.map((p) => ({ kind: "proposal", repo: node.repo, p }));
162
}
bf73998c Andy McCall 1 month ago ↶
163
if (node.kind === "proposal") {
164
try {
165
const files = await this.filesOf(node.repo, node.p);
166
return files.map((f) => ({ kind: "file", repo: node.repo, p: node.p, f }));
167
} catch (e) {
092b8bf9 Andy McCall 1 month ago ↶
168
void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`);
bf73998c Andy McCall 1 month ago ↶
169
}
170
}
f0b06dea Andy McCall 1 month ago
171
return [];
172
}
173
}
174
175
export type ProposalNode = Extract<Node, { kind: "proposal" }>;
bf73998c Andy McCall 1 month ago ↶
176
export type ProposalFileNode = Extract<Node, { kind: "file" }>;
f0b06dea Andy McCall 1 month ago
177
178
/** The detail webview. */
179
export function showDetail(repo: HubRepo, p: api.Proposal, checks: api.Check[]): void {
180
const panel = vscode.window.createWebviewPanel("oxygenhub.proposal", `#${p.number} ${p.title}`, vscode.ViewColumn.Beside, { enableCommandUris: true });
181
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
182
const changes = p.changes.map((c) => `<li><code>${c.hash.slice(0, 12)}</code> ${esc(c.message.split("\n")[0])} <span class="mute">${esc(c.author.name)} · ${c.signature.status}</span></li>`).join("");
183
const approvals = p.approvals.map((a) => `<li>${a.sealed ? "◈" : "○"} ${esc(a.account)}${a.sealed ? "" : " (seal pending)"}</li>`).join("") || "<li class=mute>none</li>";
184
const blockers = p.blockers.map((b) => `<li>✘ ${esc(b)}</li>`).join("") || "<li>✔ ready to land</li>";
185
const checkRows = checks.map((c) => `<li>${c.status === "passed" ? "✔" : c.status === "failed" ? "✘" : "…"} ${esc(c.name)} <span class=mute>${c.status}${c.summary ? " · " + esc(c.summary) : ""}</span></li>`).join("") || "<li class=mute>none</li>";
186
const url = hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number: p.number });
187
const arg = encodeURIComponent(JSON.stringify([repo.base, repo.owner, repo.name, p.number]));
188
panel.webview.html = `<!doctype html><html><body style="font-family: var(--vscode-font-family); padding: 12px">
189
<h2>#${p.number} ${esc(p.title)} <small>${p.state}</small></h2>
190
<p>${esc(p.author)} wants <b>${esc(JSON.stringify(p.source))}</b> into <b>${esc(p.target)}</b> · ${p.changes.length} change(s)${p.conflicted ? " · <b>conflicts</b>" : ""}</p>
bf73998c Andy McCall 1 month ago ↶
191
<p><a href="${url}">Open on hub</a> · <a href="command:oxygenhub.openProposalChanges?${arg}">View changes</a> · <a href="command:oxygenhub.checkoutProposal?${arg}">Check out</a> · <a href="command:oxygenhub.review?${arg}">Review</a> · <a href="command:oxygenhub.sealApproval?${arg}">Seal approval</a> · <a href="command:oxygenhub.land?${arg}">Land</a></p>
f0b06dea Andy McCall 1 month ago
192
<div>${p.body ? `<pre style="white-space: pre-wrap">${esc(p.body)}</pre>` : ""}</div>
193
<h3>Landing</h3><ul>${blockers}</ul>
194
<h3>Approvals</h3><ul>${approvals}</ul>
195
<h3>Checks</h3><ul>${checkRows}</ul>
196
<h3>Changes</h3><ul>${changes}</ul>
197
<style>.mute{opacity:.7} small{font-weight:normal;opacity:.7}</style>
198
</body></html>`;
199
}