Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

113 lines · 4.8 KBRaw History
1import path from "node:path";
2import { enc } from "../api.js";
3import { exactlyOne, parseRepoArg, repoPath, str, type Command, type Group } from "../cli.js";
4import { basicAuthHeader, gitInteractive, withGitConfig } from "../git.js";
5import { CliError } from "../io.js";
6import { ago, json, table, truncate } from "../output.js";
7
8export interface RepoJson {
9 id: string;
10 name: string;
11 full_name: string;
12 owner: { login: string; type: string };
13 private: boolean;
14 visibility: "public" | "private";
15 description: string | null;
16 default_branch: string;
17 archived: boolean;
18 html_url: string;
19 clone_url: string;
20 pushed_at: string | null;
21 updated_at: string;
22 permissions?: { role: string };
23 stats?: Record<string, unknown>;
24}
25
26const create: Command = {
27 name: "repo create",
28 summary: "Create a repository",
29 args: "<name>",
30 options: {
31 org: { type: "string", desc: "Create it in this organization", arg: "ORG" },
32 private: { type: "boolean", desc: "Only collaborators can see it (default)" },
33 public: { type: "boolean", desc: "Anyone can see it" },
34 description: { type: "string", short: "d", desc: "Description", arg: "TEXT" },
35 "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" },
36 },
37 json: true,
38 minArgs: 1,
39 maxArgs: 1,
40 async run(ctx, o, [name]) {
41 const visibility = exactlyOne(o, ["private", "public"], "private");
42 const api = await ctx.authedApi();
43 const org = str(o, "org");
44 const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") });
45 if (ctx.json) return json(ctx.io, r);
46 ctx.out(`${ctx.c.green("✓")} Created ${ctx.c.bold(r.full_name)} (${r.visibility})`);
47 ctx.out(r.html_url);
48 ctx.err(ctx.c.dim(`git remote add origin ${r.clone_url}`));
49 },
50};
51
52const listCmd: Command = {
53 name: "repo list",
54 summary: "List repositories you can see",
55 options: { owner: { type: "string", desc: "Only repositories owned by this user or organization", arg: "OWNER" } },
56 json: true,
57 maxArgs: 0,
58 async run(ctx, o) {
59 const api = await ctx.api();
60 const owner = str(o, "owner");
61 // The server has no /users/:login/repos; /orgs/:owner/repos accepts any account login.
62 const repos = await api.get<RepoJson[]>(owner ? `/users/${enc(owner)}/repos` : "/user/repos");
63 if (ctx.json) return json(ctx.io, repos);
64 if (!repos.length) return ctx.err("No repositories.");
65 ctx.io.stdout.write(table(repos.map((r) => [ctx.c.bold(r.full_name), r.archived ? ctx.c.dim("archived") : r.visibility, truncate(r.description, 50), ctx.c.dim(ago(r.pushed_at ?? r.updated_at))])));
66 },
67};
68
69const view: Command = {
70 name: "repo view",
71 summary: "Show a repository",
72 args: "[OWNER/NAME]",
73 repo: true,
74 json: true,
75 maxArgs: 1,
76 async run(ctx, _o, [arg]) {
77 const ref = arg ? parseRepoArg(arg) : await ctx.repo();
78 const r = await (await ctx.api()).get<RepoJson>(repoPath(ref));
79 if (ctx.json) return json(ctx.io, r);
80 const c = ctx.c;
81 ctx.out(`${c.bold(r.full_name)} ${c.dim(r.visibility)}${r.archived ? c.yellow(" archived") : ""}`);
82 if (r.description) ctx.out(r.description);
83 const rows: string[][] = [
84 ["default branch", r.default_branch],
85 ["web", r.html_url],
86 ["clone", r.clone_url],
87 ];
88 if (r.permissions?.role) rows.push(["your role", r.permissions.role]);
89 if (r.pushed_at) rows.push(["last push", ago(r.pushed_at)]);
90 for (const [k, v] of Object.entries(r.stats ?? {})) if (typeof v === "number" || typeof v === "string") rows.push([k.replace(/_/g, " "), String(v)]);
91 ctx.io.stdout.write(table(rows.map(([k, v]) => [c.dim(k!), v!])));
92 },
93};
94
95const clone: Command = {
96 name: "repo clone",
97 summary: "Clone a repository",
98 description: "The token is passed to this one git command as an HTTP header and is never written to .git/config.\nRun 'diffinery auth setup-git' so later fetches and pushes authenticate too.",
99 args: "<OWNER/NAME> [directory]",
100 minArgs: 1,
101 maxArgs: 2,
102 async run(ctx, _o, [arg, dir]) {
103 const ref = parseRepoArg(arg!);
104 const cfg = await ctx.config();
105 const url = `${cfg.url}/${ref.owner}/${ref.name}.git`;
106 const env = cfg.token ? withGitConfig(ctx.io.env, [basicAuthHeader(cfg.url, "x-token", cfg.token), ["protocol.version", "2"]]) : withGitConfig(ctx.io.env, [["protocol.version", "2"]]);
107 const code = await gitInteractive(ctx.io, ["clone", url, ...(dir ? [dir] : [])], { env });
108 if (code !== 0) throw new CliError(`git clone exited with ${code}`, code);
109 ctx.err(ctx.c.dim(`Cloned into ${dir ?? path.basename(ref.name)}. Tip: 'diffinery auth setup-git' lets plain git push/fetch authenticate.`));
110 },
111};
112
113export const repoGroup: Group = { name: "repo", summary: "Create, list, view and clone repositories", commands: [create, listCmd, view, clone] };