OXYGENHUB
# 52cef00143db oxygenhub-vscode / src / proposals.ts
7.2 KiB · last changed 0.1.0: sign in, hub client with generated types, remote provider, proposals and issues views, review threads on atoms, seal and land through oxy, checks status bar 1 month 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
/** 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
}