@profullstack/g1tz 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/bin/g1tz.mjs +2 -0
- package/dist/diff.d.ts +44 -0
- package/dist/diff.js +112 -0
- package/dist/git.d.ts +61 -0
- package/dist/git.js +182 -0
- package/dist/github.d.ts +63 -0
- package/dist/github.js +154 -0
- package/dist/main.d.ts +71 -0
- package/dist/main.js +458 -0
- package/dist/pulse-view.d.ts +62 -0
- package/dist/pulse-view.js +244 -0
- package/dist/pulse.d.ts +120 -0
- package/dist/pulse.js +348 -0
- package/dist/traffic.d.ts +37 -0
- package/dist/traffic.js +68 -0
- package/package.json +53 -0
- package/src/diff.ts +147 -0
- package/src/git.ts +225 -0
- package/src/github.ts +213 -0
- package/src/main.ts +466 -0
- package/src/pulse-view.ts +294 -0
- package/src/pulse.ts +413 -0
- package/src/traffic.ts +100 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything g1tz knows about a repository, read by shelling out to git.
|
|
3
|
+
*
|
|
4
|
+
* Porcelain formats only, with explicit -z where a filename could contain a
|
|
5
|
+
* newline. Parsing `git status` output meant for humans is how a TUI ends up
|
|
6
|
+
* corrupting someone's working tree.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
export interface FileChange {
|
|
11
|
+
/** Index status, as git reports it: M, A, D, R, C, U or space. */
|
|
12
|
+
index: string;
|
|
13
|
+
/** Worktree status. */
|
|
14
|
+
work: string;
|
|
15
|
+
path: string;
|
|
16
|
+
/** Original path for a rename. */
|
|
17
|
+
from?: string;
|
|
18
|
+
staged: boolean;
|
|
19
|
+
unstaged: boolean;
|
|
20
|
+
untracked: boolean;
|
|
21
|
+
conflicted: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Commit {
|
|
25
|
+
hash: string;
|
|
26
|
+
short: string;
|
|
27
|
+
subject: string;
|
|
28
|
+
author: string;
|
|
29
|
+
when: string;
|
|
30
|
+
refs: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Branch {
|
|
34
|
+
name: string;
|
|
35
|
+
current: boolean;
|
|
36
|
+
upstream: string;
|
|
37
|
+
ahead: number;
|
|
38
|
+
behind: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GitError {
|
|
42
|
+
command: string;
|
|
43
|
+
message: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Repo {
|
|
47
|
+
root: string;
|
|
48
|
+
branch: string;
|
|
49
|
+
upstream: string;
|
|
50
|
+
ahead: number;
|
|
51
|
+
behind: number;
|
|
52
|
+
files: FileChange[];
|
|
53
|
+
commits: Commit[];
|
|
54
|
+
branches: Branch[];
|
|
55
|
+
errors: GitError[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Run git and return stdout, or null when it fails. Never throws. */
|
|
59
|
+
export function git(cwd: string, args: string[]): string | null {
|
|
60
|
+
const result = spawnSync("git", args, {
|
|
61
|
+
cwd,
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
64
|
+
// A pager or an editor would take the terminal away from the TUI.
|
|
65
|
+
env: { ...process.env, GIT_PAGER: "cat", GIT_EDITOR: "true", GIT_OPTIONAL_LOCKS: "0" },
|
|
66
|
+
});
|
|
67
|
+
if (result.error || result.status !== 0) return null;
|
|
68
|
+
return result.stdout;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function repoRoot(cwd: string): string | null {
|
|
72
|
+
return git(cwd, ["rev-parse", "--show-toplevel"])?.trim() || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `git status --porcelain=v2 -z`. v2 rather than v1 because it reports the
|
|
77
|
+
* branch, the upstream and the ahead/behind counts in the same call, and -z
|
|
78
|
+
* because a path may contain anything but NUL.
|
|
79
|
+
*/
|
|
80
|
+
export function parseStatus(out: string): Pick<Repo, "branch" | "upstream" | "ahead" | "behind" | "files"> {
|
|
81
|
+
const result = { branch: "", upstream: "", ahead: 0, behind: 0, files: [] as FileChange[] };
|
|
82
|
+
const records = out.split("\0");
|
|
83
|
+
for (let i = 0; i < records.length; i++) {
|
|
84
|
+
const line = records[i] as string;
|
|
85
|
+
if (line === "") continue;
|
|
86
|
+
if (line.startsWith("# branch.head ")) {
|
|
87
|
+
result.branch = line.slice("# branch.head ".length);
|
|
88
|
+
} else if (line.startsWith("# branch.upstream ")) {
|
|
89
|
+
result.upstream = line.slice("# branch.upstream ".length);
|
|
90
|
+
} else if (line.startsWith("# branch.ab ")) {
|
|
91
|
+
const m = /\+(-?\d+) -(-?\d+)/.exec(line);
|
|
92
|
+
if (m) { result.ahead = Number(m[1]); result.behind = Number(m[2]); }
|
|
93
|
+
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
94
|
+
const rename = line.startsWith("2 ");
|
|
95
|
+
const parts = line.split(" ");
|
|
96
|
+
const xy = parts[1] ?? "..";
|
|
97
|
+
const index = xy[0] ?? ".";
|
|
98
|
+
const work = xy[1] ?? ".";
|
|
99
|
+
// Fields are fixed up to the path, which is the rest of the record.
|
|
100
|
+
const pathStart = rename ? 9 : 8;
|
|
101
|
+
const path = parts.slice(pathStart).join(" ");
|
|
102
|
+
// A rename's original path is the *next* NUL-separated record.
|
|
103
|
+
const from = rename ? (records[++i] as string | undefined) : undefined;
|
|
104
|
+
result.files.push({
|
|
105
|
+
index, work, path, from,
|
|
106
|
+
staged: index !== "." && index !== " ",
|
|
107
|
+
unstaged: work !== "." && work !== " ",
|
|
108
|
+
untracked: false,
|
|
109
|
+
conflicted: false,
|
|
110
|
+
});
|
|
111
|
+
} else if (line.startsWith("u ")) {
|
|
112
|
+
const parts = line.split(" ");
|
|
113
|
+
result.files.push({
|
|
114
|
+
index: "U", work: "U",
|
|
115
|
+
path: parts.slice(10).join(" "),
|
|
116
|
+
staged: false, unstaged: true, untracked: false, conflicted: true,
|
|
117
|
+
});
|
|
118
|
+
} else if (line.startsWith("? ")) {
|
|
119
|
+
result.files.push({
|
|
120
|
+
index: "?", work: "?", path: line.slice(2),
|
|
121
|
+
staged: false, unstaged: true, untracked: true, conflicted: false,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const LOG_FORMAT = "%H%x1f%h%x1f%s%x1f%an%x1f%ar%x1f%D";
|
|
129
|
+
|
|
130
|
+
export function parseLog(out: string): Commit[] {
|
|
131
|
+
const commits: Commit[] = [];
|
|
132
|
+
for (const raw of out.split("\0")) {
|
|
133
|
+
// `--pretty=format:` puts a newline BETWEEN records, after the NUL, so
|
|
134
|
+
// every record but the first starts with one. Left in, it rode along on
|
|
135
|
+
// the hash and `git show` failed for every commit but the first.
|
|
136
|
+
const line = raw.replace(/^\n/, "");
|
|
137
|
+
if (line === "") continue;
|
|
138
|
+
const [hash, short, subject, author, when, refs] = line.split("\x1f");
|
|
139
|
+
if (!hash) continue;
|
|
140
|
+
commits.push({
|
|
141
|
+
hash, short: short ?? "", subject: subject ?? "",
|
|
142
|
+
author: author ?? "", when: when ?? "", refs: refs ?? "",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return commits;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const BRANCH_FORMAT = "%(HEAD)%1f%(refname:short)%1f%(upstream:short)%1f%(upstream:track)";
|
|
149
|
+
|
|
150
|
+
export function parseBranches(out: string): Branch[] {
|
|
151
|
+
const branches: Branch[] = [];
|
|
152
|
+
for (const line of out.split("\n")) {
|
|
153
|
+
if (line.trim() === "") continue;
|
|
154
|
+
const [head, name, upstream, track] = line.split("\x1f");
|
|
155
|
+
if (!name) continue;
|
|
156
|
+
const ahead = /ahead (\d+)/.exec(track ?? "");
|
|
157
|
+
const behind = /behind (\d+)/.exec(track ?? "");
|
|
158
|
+
branches.push({
|
|
159
|
+
name,
|
|
160
|
+
current: head === "*",
|
|
161
|
+
upstream: upstream ?? "",
|
|
162
|
+
ahead: ahead ? Number(ahead[1]) : 0,
|
|
163
|
+
behind: behind ? Number(behind[1]) : 0,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return branches;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function readRepo(cwd: string, logLimit = 200): Repo | null {
|
|
170
|
+
const root = repoRoot(cwd);
|
|
171
|
+
if (!root) return null;
|
|
172
|
+
const errors: GitError[] = [];
|
|
173
|
+
const run = (args: string[]): string => {
|
|
174
|
+
const out = git(root, args);
|
|
175
|
+
if (out === null) {
|
|
176
|
+
errors.push({ command: `git ${args[0]}`, message: "failed" });
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// --untracked-files=all, because the default collapses a new directory to a
|
|
183
|
+
// single "? sub/" entry and you cannot stage or diff a directory here.
|
|
184
|
+
const status = parseStatus(
|
|
185
|
+
run(["status", "--porcelain=v2", "--branch", "--untracked-files=all", "-z"]),
|
|
186
|
+
);
|
|
187
|
+
// --no-show-signature: with log.showSignature set, git writes the
|
|
188
|
+
// verification text on stdout ahead of every record, glued to the hash.
|
|
189
|
+
const commits = parseLog(run(["log", "--no-show-signature", `--pretty=format:${LOG_FORMAT}%x00`, "-n", String(logLimit)]));
|
|
190
|
+
const branches = parseBranches(run(["for-each-ref", "--sort=-committerdate",
|
|
191
|
+
`--format=${BRANCH_FORMAT}`, "refs/heads"]));
|
|
192
|
+
|
|
193
|
+
return { root, ...status, commits, branches, errors };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The diff for one path, staged or not. Empty string when there is none. */
|
|
197
|
+
export function fileDiff(root: string, file: FileChange, staged: boolean): string {
|
|
198
|
+
if (file.untracked) {
|
|
199
|
+
const out = git(root, ["diff", "--no-color", "--no-index", "/dev/null", file.path]);
|
|
200
|
+
// --no-index exits 1 when the files differ, which is always here.
|
|
201
|
+
return out ?? git(root, ["show", `:${file.path}`]) ?? "(untracked)";
|
|
202
|
+
}
|
|
203
|
+
const args = ["diff", "--no-color"];
|
|
204
|
+
if (staged) args.push("--cached");
|
|
205
|
+
args.push("--", file.path);
|
|
206
|
+
return git(root, args) ?? "";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Colour family for a diff line, as a theme key. */
|
|
210
|
+
export function diffLineKind(line: string): "success" | "danger" | "accent" | "muted" | "foreground" {
|
|
211
|
+
if (line.startsWith("+++") || line.startsWith("---")) return "muted";
|
|
212
|
+
if (line.startsWith("@@")) return "accent";
|
|
213
|
+
if (line.startsWith("+")) return "success";
|
|
214
|
+
if (line.startsWith("-")) return "danger";
|
|
215
|
+
if (line.startsWith("diff ") || line.startsWith("index ")) return "muted";
|
|
216
|
+
return "foreground";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function stage(root: string, file: FileChange): boolean {
|
|
220
|
+
return git(root, ["add", "--", file.path]) !== null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function unstage(root: string, file: FileChange): boolean {
|
|
224
|
+
return git(root, ["restore", "--staged", "--", file.path]) !== null;
|
|
225
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub half of Pulse: pull requests, issues, releases and stars over the
|
|
3
|
+
* period, read through the gh CLI. gh holds the login, so nothing here ever
|
|
4
|
+
* sees a token, and a machine without gh (or without a login) simply gets the
|
|
5
|
+
* git half on its own.
|
|
6
|
+
*
|
|
7
|
+
* Pages are walked newest-first and stop at the first item older than the
|
|
8
|
+
* period, under a small page budget, so a busy repository on "all time"
|
|
9
|
+
* cannot turn one keypress into a thousand requests. When the budget runs out
|
|
10
|
+
* the counts are floors and the result says so.
|
|
11
|
+
*/
|
|
12
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
13
|
+
import { git } from "./git.ts";
|
|
14
|
+
|
|
15
|
+
export interface GitHubRepo {
|
|
16
|
+
owner: string;
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GitHubItem {
|
|
21
|
+
number: number;
|
|
22
|
+
title: string;
|
|
23
|
+
user: string;
|
|
24
|
+
/** ISO date of the event this item is listed for: opened, merged or closed. */
|
|
25
|
+
at: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GitHubRelease {
|
|
29
|
+
tag: string;
|
|
30
|
+
name: string;
|
|
31
|
+
at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GitHubPulse {
|
|
35
|
+
repo: string;
|
|
36
|
+
url: string;
|
|
37
|
+
description: string;
|
|
38
|
+
stars: number;
|
|
39
|
+
forks: number;
|
|
40
|
+
/** GitHub's open issue count, which includes open pull requests. */
|
|
41
|
+
openIssues: number;
|
|
42
|
+
prsOpened: GitHubItem[];
|
|
43
|
+
prsMerged: GitHubItem[];
|
|
44
|
+
/** Closed without merging. */
|
|
45
|
+
prsClosed: GitHubItem[];
|
|
46
|
+
issuesOpened: GitHubItem[];
|
|
47
|
+
issuesClosed: GitHubItem[];
|
|
48
|
+
releases: GitHubRelease[];
|
|
49
|
+
newStars: number;
|
|
50
|
+
/** A page budget ran out somewhere, so a count is a floor rather than a total. */
|
|
51
|
+
partial: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* https, ssh and scp-style GitHub remotes, with or without .git, including
|
|
56
|
+
* GitHub's "SSH over the HTTPS port" form (ssh.github.com:443) and an explicit
|
|
57
|
+
* port. Anything else is null.
|
|
58
|
+
*/
|
|
59
|
+
export function parseGitHubRemote(url: string): GitHubRepo | null {
|
|
60
|
+
const m = /^(?:https?:\/\/(?:[^@/]+@)?|git@|ssh:\/\/(?:git@)?)(?:ssh\.)?github\.com(?::\d+)?[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
61
|
+
return m ? { owner: m[1] as string, name: m[2] as string } : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function githubRemote(root: string): GitHubRepo | null {
|
|
65
|
+
const url = git(root, ["remote", "get-url", "origin"]);
|
|
66
|
+
return url ? parseGitHubRemote(url) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const repoName = (repo: GitHubRepo): string => `${repo.owner}/${repo.name}`;
|
|
70
|
+
|
|
71
|
+
/** One GitHub API call: a path relative to the API root, parsed JSON back. */
|
|
72
|
+
export type Fetch = (path: string, headers?: Record<string, string>) => Promise<unknown>;
|
|
73
|
+
|
|
74
|
+
let ghPresent: boolean | null = null;
|
|
75
|
+
|
|
76
|
+
/** Whether gh is on the PATH. Asked once per process. */
|
|
77
|
+
export function ghInstalled(): boolean {
|
|
78
|
+
if (ghPresent === null) ghPresent = spawnSync("gh", ["--version"], { encoding: "utf8" }).status === 0;
|
|
79
|
+
return ghPresent;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What to tell the user when gh could not answer. */
|
|
83
|
+
export function ghErrorMessage(
|
|
84
|
+
error: { code?: string | number | null | undefined; signal?: string | null | undefined; killed?: boolean | undefined; message: string },
|
|
85
|
+
stderr: string,
|
|
86
|
+
): string {
|
|
87
|
+
if (error.code === "ENOENT") return "gh is not installed";
|
|
88
|
+
// `killed` is execFile's own timeout; a signal is gh dying to something else. Neither leaves anything on stderr.
|
|
89
|
+
if (error.killed || error.signal) return "GitHub did not answer in time (gh was stopped after 60 seconds)";
|
|
90
|
+
const s = stderr.trim();
|
|
91
|
+
if (/gh auth login|not logged in|authentication required|HTTP 401/i.test(s)) return "gh is not logged in (run gh auth login)";
|
|
92
|
+
if (/rate limit/i.test(s)) return "GitHub API rate limit exceeded; try again later";
|
|
93
|
+
if (/HTTP 404/.test(s)) return "repository not found on GitHub, or the gh login cannot see it";
|
|
94
|
+
return s.split("\n")[0] || error.message;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ghFetch(path: string, headers: Record<string, string> = {}): Promise<unknown> {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const args = ["api", path];
|
|
100
|
+
for (const [k, v] of Object.entries(headers)) args.push("-H", `${k}: ${v}`);
|
|
101
|
+
execFile("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 60_000 }, (error, stdout, stderr) => {
|
|
102
|
+
if (error) {
|
|
103
|
+
reject(new Error(ghErrorMessage(error, stderr)));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
resolve(JSON.parse(stdout));
|
|
108
|
+
} catch {
|
|
109
|
+
reject(new Error("gh returned something that is not JSON"));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface PageBudget {
|
|
116
|
+
pulls: number;
|
|
117
|
+
issues: number;
|
|
118
|
+
releases: number;
|
|
119
|
+
stars: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const PAGE_BUDGET: PageBudget = { pulls: 5, issues: 5, releases: 2, stars: 3 };
|
|
123
|
+
|
|
124
|
+
interface UserJson { login?: string }
|
|
125
|
+
interface PullJson { number: number; title: string; user?: UserJson; created_at: string; updated_at: string; closed_at: string | null; merged_at: string | null }
|
|
126
|
+
interface IssueJson { number: number; title: string; user?: UserJson; created_at: string; updated_at: string; closed_at: string | null; pull_request?: unknown }
|
|
127
|
+
interface ReleaseJson { tag_name: string; name: string | null; published_at: string | null; created_at: string; draft?: boolean }
|
|
128
|
+
interface StarJson { starred_at: string }
|
|
129
|
+
interface RepoJson { stargazers_count?: number; forks_count?: number; open_issues_count?: number; html_url?: string; description?: string | null }
|
|
130
|
+
|
|
131
|
+
const STAR_HEADERS = { Accept: "application/vnd.github.star+json" };
|
|
132
|
+
|
|
133
|
+
/** Walk numbered pages until `stop` says an item is older than needed, a short page ends the list, or the budget is spent. */
|
|
134
|
+
async function walk<T>(fetch: Fetch, path: string, pages: number, stop: (item: T) => boolean): Promise<{ items: T[]; partial: boolean }> {
|
|
135
|
+
const items: T[] = [];
|
|
136
|
+
for (let page = 1; page <= pages; page++) {
|
|
137
|
+
const data = (await fetch(`${path}&page=${page}`)) as T[];
|
|
138
|
+
if (!Array.isArray(data) || data.length === 0) return { items, partial: false };
|
|
139
|
+
for (const item of data) {
|
|
140
|
+
if (stop(item)) return { items, partial: false };
|
|
141
|
+
items.push(item);
|
|
142
|
+
}
|
|
143
|
+
if (data.length < 100) return { items, partial: false };
|
|
144
|
+
}
|
|
145
|
+
return { items, partial: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** GitHub lists stargazers oldest first, so the count of new ones starts at the last page and walks back. */
|
|
149
|
+
async function countStars(fetch: Fetch, name: string, total: number, cutoff: number, pages: number): Promise<{ count: number; partial: boolean }> {
|
|
150
|
+
if (cutoff === 0 || total === 0) return { count: total, partial: false };
|
|
151
|
+
const lastPage = Math.max(1, Math.ceil(total / 100));
|
|
152
|
+
let count = 0;
|
|
153
|
+
for (let page = lastPage, used = 0; page >= 1; page--, used++) {
|
|
154
|
+
if (used >= pages) return { count, partial: true };
|
|
155
|
+
let data: StarJson[];
|
|
156
|
+
try {
|
|
157
|
+
data = (await fetch(`repos/${name}/stargazers?per_page=100&page=${page}`, STAR_HEADERS)) as StarJson[];
|
|
158
|
+
} catch {
|
|
159
|
+
// GitHub refuses the stargazers list of a very large repository (facebook/react
|
|
160
|
+
// answers 404 on every page), and a rate limit can land here after everything
|
|
161
|
+
// else answered. The star count is the only casualty, not the whole read.
|
|
162
|
+
return { count, partial: true };
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(data)) return { count, partial: false };
|
|
165
|
+
for (let i = data.length - 1; i >= 0; i--) {
|
|
166
|
+
if (Date.parse((data[i] as StarJson).starred_at) < cutoff) return { count, partial: false };
|
|
167
|
+
count++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { count, partial: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const item = (x: { number: number; title: string; user?: UserJson }, at: string): GitHubItem =>
|
|
174
|
+
({ number: x.number, title: x.title, user: x.user?.login ?? "", at });
|
|
175
|
+
const newest = (a: GitHubItem, b: GitHubItem): number => b.at.localeCompare(a.at);
|
|
176
|
+
|
|
177
|
+
export async function readGitHubPulse(repo: GitHubRepo, since: Date | null, fetch: Fetch = ghFetch, budget: PageBudget = PAGE_BUDGET): Promise<GitHubPulse> {
|
|
178
|
+
const name = repoName(repo);
|
|
179
|
+
const cutoff = since ? since.getTime() : 0;
|
|
180
|
+
const inRange = (iso: string | null | undefined): boolean => iso !== null && iso !== undefined && Date.parse(iso) >= cutoff;
|
|
181
|
+
const stale = (iso: string | null | undefined): boolean => !inRange(iso);
|
|
182
|
+
|
|
183
|
+
const info = (await fetch(`repos/${name}`)) as RepoJson;
|
|
184
|
+
// Sorted by last update, newest first: the first one untouched in the period ends the walk.
|
|
185
|
+
const pulls = await walk<PullJson>(fetch, `repos/${name}/pulls?state=all&sort=updated&direction=desc&per_page=100`, budget.pulls, (p) => stale(p.updated_at));
|
|
186
|
+
const sinceParam = since ? `&since=${since.toISOString()}` : "";
|
|
187
|
+
const issues = await walk<IssueJson>(fetch, `repos/${name}/issues?state=all&sort=updated&direction=desc&per_page=100${sinceParam}`, budget.issues, (i) => stale(i.updated_at));
|
|
188
|
+
// Releases are listed by the tagged commit's date, not by when they were
|
|
189
|
+
// published, so a release cut this week for an older tag sits below older
|
|
190
|
+
// ones: no early stop, the budget's pages are read and published_at decides.
|
|
191
|
+
const releases = await walk<ReleaseJson>(fetch, `repos/${name}/releases?per_page=100`, budget.releases, () => false);
|
|
192
|
+
const stars = await countStars(fetch, name, info.stargazers_count ?? 0, cutoff, budget.stars);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
repo: name,
|
|
196
|
+
url: info.html_url ?? `https://github.com/${name}`,
|
|
197
|
+
description: info.description ?? "",
|
|
198
|
+
stars: info.stargazers_count ?? 0,
|
|
199
|
+
forks: info.forks_count ?? 0,
|
|
200
|
+
openIssues: info.open_issues_count ?? 0,
|
|
201
|
+
prsOpened: pulls.items.filter((p) => inRange(p.created_at)).map((p) => item(p, p.created_at)).sort(newest),
|
|
202
|
+
prsMerged: pulls.items.filter((p) => inRange(p.merged_at)).map((p) => item(p, p.merged_at as string)).sort(newest),
|
|
203
|
+
prsClosed: pulls.items.filter((p) => !p.merged_at && inRange(p.closed_at)).map((p) => item(p, p.closed_at as string)).sort(newest),
|
|
204
|
+
issuesOpened: issues.items.filter((i) => !i.pull_request && inRange(i.created_at)).map((i) => item(i, i.created_at)).sort(newest),
|
|
205
|
+
issuesClosed: issues.items.filter((i) => !i.pull_request && inRange(i.closed_at)).map((i) => item(i, i.closed_at as string)).sort(newest),
|
|
206
|
+
releases: releases.items
|
|
207
|
+
.filter((r) => !r.draft && inRange(r.published_at))
|
|
208
|
+
.map((r) => ({ tag: r.tag_name, name: r.name ?? r.tag_name, at: r.published_at as string }))
|
|
209
|
+
.sort((a, b) => b.at.localeCompare(a.at)),
|
|
210
|
+
newStars: stars.count,
|
|
211
|
+
partial: pulls.partial || issues.partial || releases.partial || stars.partial,
|
|
212
|
+
};
|
|
213
|
+
}
|