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
19
/** Find the hub remotes of every repository the Oxygen extension knows. */
20
export async function hubRepos(oxygen: OxygenAPI, auth: HubAuth): Promise<HubRepo[]> {
21
const out: HubRepo[] = [];
22
const bases = auth.bases();
23
for (const handle of oxygen.repositories) {
24
let remotes: { name: string; url: string }[] = [];
25
try {
26
remotes = await handle.remotes();
27
} catch {
28
continue;
29
}
30
for (const r of remotes) {
31
const p = parseRepoUrl(r.url);
32
if (p && bases.includes(p.base)) {
33
out.push({ handle, base: p.base, owner: p.owner, name: p.name, remote: r.name });
34
break;
35
}
36
}
37
}
38
return out;
39
}
40
41
type Node = { kind: "repo"; repo: HubRepo } | { kind: "bucket"; repo: HubRepo; label: string; items: api.Proposal[] } | { kind: "proposal"; repo: HubRepo; p: api.Proposal };
42
43
export class ProposalsProvider implements vscode.TreeDataProvider<Node> {
44
private readonly emitter = new vscode.EventEmitter<void>();
45
readonly onDidChangeTreeData = this.emitter.event;
46
private cache = new Map<string, api.Proposal[]>();
47
48
constructor(
49
private readonly oxygen: () => OxygenAPI | undefined,
50
private readonly auth: HubAuth,
51
) {}
52
53
refresh(): void {
54
this.cache.clear();
55
this.emitter.fire();
56
}
57
58
async repos(): Promise<HubRepo[]> {
59
const o = this.oxygen();
60
return o ? hubRepos(o, this.auth) : [];
61
}
62
63
async proposalsOf(repo: HubRepo): Promise<api.Proposal[]> {
64
const key = `${repo.base}/${repo.owner}/${repo.name}`;
65
const hit = this.cache.get(key);
66
if (hit) {
67
return hit;
68
}
69
const list = await this.auth.clientFor(repo.base).proposals(repo.owner, repo.name);
70
this.cache.set(key, list);
71
return list;
72
}
73
74
getTreeItem(node: Node): vscode.TreeItem {
75
switch (node.kind) {
76
case "repo": {
77
const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded);
78
item.iconPath = new vscode.ThemeIcon("repo");
79
item.contextValue = "repo";
80
return item;
81
}
82
case "bucket": {
83
const item = new vscode.TreeItem(`${node.label} (${node.items.length})`, node.items.length ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed);
84
item.contextValue = "bucket";
85
return item;
86
}
87
case "proposal": {
88
const p = node.p;
89
const item = new vscode.TreeItem(`#${p.number} ${p.title}`, vscode.TreeItemCollapsibleState.None);
90
const sealed = p.approvals.filter((a) => a.sealed).length;
91
const chips = [p.state === "draft" ? "draft" : "", p.conflicted ? "conflicts" : "", p.landable ? "landable" : "", sealed ? `${sealed} sealed` : ""].filter(Boolean).join(" · ");
92
item.description = `${p.author} → ${p.target}${chips ? " · " + chips : ""}`;
93
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"}`);
94
item.iconPath = new vscode.ThemeIcon(p.conflicted ? "warning" : p.landable ? "pass" : p.state === "draft" ? "circle-outline" : "git-pull-request");
95
item.contextValue = "proposal";
96
item.command = { title: "Open", command: "oxygenhub.openProposal", arguments: [node] };
97
return item;
98
}
99
}
100
}
101
102
async getChildren(node?: Node): Promise<Node[]> {
103
if (!node) {
104
const repos = await this.repos();
105
return repos.map((repo) => ({ kind: "repo", repo }));
106
}
107
if (node.kind === "repo") {
108
let list: api.Proposal[] = [];
109
try {
110
list = await this.proposalsOf(node.repo);
111
} catch (e) {
112
void vscode.window.showWarningMessage(`Oxygenhub: ${(e as Error).message}`);
113
return [];
114
}
115
const me = this.auth.sessionFor(node.repo.base)?.username;
116
const mine = list.filter((p) => p.author === me);
117
const needing = list.filter((p) => needsMe(p, me));
118
const rest = list.filter((p) => !mine.includes(p) && !needing.includes(p));
119
return [
120
{ kind: "bucket", repo: node.repo, label: "Needing me", items: needing },
121
{ kind: "bucket", repo: node.repo, label: "Mine", items: mine },
122
{ kind: "bucket", repo: node.repo, label: "Open", items: rest },
123
];
124
}
125
if (node.kind === "bucket") {
126
return node.items.map((p) => ({ kind: "proposal", repo: node.repo, p }));
127
}
128
return [];
129
}
130
}
131
132
export type ProposalNode = Extract<Node, { kind: "proposal" }>;
133
134
/** The detail webview. */
135
export function showDetail(repo: HubRepo, p: api.Proposal, checks: api.Check[]): void {
136
const panel = vscode.window.createWebviewPanel("oxygenhub.proposal", `#${p.number} ${p.title}`, vscode.ViewColumn.Beside, { enableCommandUris: true });
137
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
138
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("");
139
const approvals = p.approvals.map((a) => `<li>${a.sealed ? "◈" : "○"} ${esc(a.account)}${a.sealed ? "" : " (seal pending)"}</li>`).join("") || "<li class=mute>none</li>";
140
const blockers = p.blockers.map((b) => `<li>✘ ${esc(b)}</li>`).join("") || "<li>✔ ready to land</li>";
141
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>";
142
const url = hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number: p.number });
143
const arg = encodeURIComponent(JSON.stringify([repo.base, repo.owner, repo.name, p.number]));
144
panel.webview.html = `<!doctype html><html><body style="font-family: var(--vscode-font-family); padding: 12px">
145
<h2>#${p.number} ${esc(p.title)} <small>${p.state}</small></h2>
146
<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>
147
<p><a href="${url}">Open on hub</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>
148
<div>${p.body ? `<pre style="white-space: pre-wrap">${esc(p.body)}</pre>` : ""}</div>
149
<h3>Landing</h3><ul>${blockers}</ul>
150
<h3>Approvals</h3><ul>${approvals}</ul>
151
<h3>Checks</h3><ul>${checkRows}</ul>
152
<h3>Changes</h3><ul>${changes}</ul>
153
<style>.mute{opacity:.7} small{font-weight:normal;opacity:.7}</style>
154
</body></html>`;
155
}