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