OXYGENHUB
◆ main oxygenhub-vscode / src / proposals.ts
9.4 KiB · last changed OxygenHub 0.13.0: Drops in the editor with Share as a Drop (closes #7); Flow approvals, expedite, reports, timings and test results (closes #8); ready/draft, landable, trust reports, asset downloads and new repositories (closes #9) 6 days ago source blame history raw
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
/** Just enough to name a repository on a hub. */
20
export type RepoRef = Pick<HubRepo, "base" | "owner" | "name">;
21
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; ca?: 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 && r.ca) {
36
        auth.setCa(p.base, r.ca);
37
      }
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
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" };
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[]>();
59
  private files = new Map<string, api.ProposalFile[]>();
60
61
  constructor(
62
    private readonly oxygen: () => OxygenAPI | undefined,
63
    private readonly auth: HubAuth,
64
  ) {}
65
66
  refresh(): void {
67
    this.cache.clear();
68
    this.files.clear();
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
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
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;
115
        const item = new vscode.TreeItem(`#${p.number} ${p.title}`, vscode.TreeItemCollapsibleState.Collapsed);
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
        // `proposal` or `proposalDraft`: a draft is marked ready, an open one turned back into a draft.
122
        item.contextValue = p.state === "draft" ? "proposalDraft" : "proposal";
123
        item.command = { title: "Open", command: "oxygenhub.openProposal", arguments: [node] };
124
        return item;
125
      }
126
      case "file": {
127
        const f = node.f;
128
        const slash = f.path.lastIndexOf("/");
129
        const item = new vscode.TreeItem(f.path.slice(slash + 1), vscode.TreeItemCollapsibleState.None);
130
        const dir = slash > 0 ? f.path.slice(0, slash) : "";
131
        item.description = [dir, f.from ? `from ${f.from}` : "", f.added || f.removed ? `+${f.added} −${f.removed}` : f.kind].filter(Boolean).join(" · ");
132
        item.tooltip = `${f.path} (${f.kind})`;
133
        item.iconPath = new vscode.ThemeIcon(FILE_ICONS[f.kind] ?? "diff");
134
        item.contextValue = "proposalFile";
135
        item.command = { title: "Open Diff", command: "oxygenhub.openProposalFile", arguments: [node] };
136
        return item;
137
      }
138
    }
139
  }
140
141
  async getChildren(node?: Node): Promise<Node[]> {
142
    if (!node) {
143
      const repos = await this.repos();
144
      return repos.map((repo) => ({ kind: "repo", repo }));
145
    }
146
    if (node.kind === "repo") {
147
      let list: api.Proposal[] = [];
148
      try {
149
        list = await this.proposalsOf(node.repo);
150
      } catch (e) {
151
        void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`);
152
        return [];
153
      }
154
      const me = this.auth.sessionFor(node.repo.base)?.username;
155
      const mine = list.filter((p) => p.author === me);
156
      const needing = list.filter((p) => needsMe(p, me));
157
      const rest = list.filter((p) => !mine.includes(p) && !needing.includes(p));
158
      return [
159
        { kind: "bucket", repo: node.repo, label: "Needing me", items: needing },
160
        { kind: "bucket", repo: node.repo, label: "Mine", items: mine },
161
        { kind: "bucket", repo: node.repo, label: "Open", items: rest },
162
      ];
163
    }
164
    if (node.kind === "bucket") {
165
      return node.items.map((p) => ({ kind: "proposal", repo: node.repo, p }));
166
    }
167
    if (node.kind === "proposal") {
168
      try {
169
        const files = await this.filesOf(node.repo, node.p);
170
        return files.map((f) => ({ kind: "file", repo: node.repo, p: node.p, f }));
171
      } catch (e) {
172
        void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`);
173
      }
174
    }
175
    return [];
176
  }
177
}
178
179
export type ProposalNode = Extract<Node, { kind: "proposal" }>;
180
export type ProposalFileNode = Extract<Node, { kind: "file" }>;
181
182
/** The detail webview. */
183
export function showDetail(repo: HubRepo, p: api.Proposal, checks: api.Check[]): void {
184
  const panel = vscode.window.createWebviewPanel("oxygenhub.proposal", `#${p.number} ${p.title}`, vscode.ViewColumn.Beside, { enableCommandUris: true });
185
  const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
186
  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("");
187
  const approvals = p.approvals.map((a) => `<li>${a.sealed ? "◈" : "○"} ${esc(a.account)}${a.sealed ? "" : " (seal pending)"}</li>`).join("") || "<li class=mute>none</li>";
188
  const blockers = p.blockers.map((b) => `<li>✘ ${esc(b)}</li>`).join("") || "<li>✔ ready to land</li>";
189
  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>";
190
  const url = hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number: p.number });
191
  const arg = encodeURIComponent(JSON.stringify([repo.base, repo.owner, repo.name, p.number]));
192
  panel.webview.html = `<!doctype html><html><body style="font-family: var(--vscode-font-family); padding: 12px">
193
  <h2>#${p.number} ${esc(p.title)} <small>${p.state}</small></h2>
194
  <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>
195
  <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>
196
  <div>${p.body ? `<pre style="white-space: pre-wrap">${esc(p.body)}</pre>` : ""}</div>
197
  <h3>Landing</h3><ul>${blockers}</ul>
198
  <h3>Approvals</h3><ul>${approvals}</ul>
199
  <h3>Checks</h3><ul>${checkRows}</ul>
200
  <h3>Changes</h3><ul>${changes}</ul>
201
  <style>.mute{opacity:.7} small{font-weight:normal;opacity:.7}</style>
202
  </body></html>`;
203
}