@kazoottt/github-repo-cleaner 0.1.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 +113 -0
- package/dist/auth.js +35 -0
- package/dist/cli.js +26 -0
- package/dist/domain.js +67 -0
- package/dist/github.js +130 -0
- package/dist/session-log.js +34 -0
- package/dist/ui.js +170 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KazooTTT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# github-repo-cleaner
|
|
2
|
+
|
|
3
|
+
Local terminal UI for cleaning GitHub repositories.
|
|
4
|
+
|
|
5
|
+
The tool reuses GitHub CLI authentication, loads repositories for the active GitHub user, makes forks easy to identify, and supports archive/delete workflows with a page-level second confirmation.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @kazoottt/github-repo-cleaner
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run either command:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
repo-cleaner
|
|
17
|
+
github-repo-cleaner
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Authentication
|
|
21
|
+
|
|
22
|
+
This tool expects `gh` to own credentials:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gh auth login
|
|
26
|
+
gh auth refresh -h github.com -s delete_repo
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The app reads the active token from `gh auth token` and does not store PATs or GitHub tokens.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
repo-cleaner
|
|
35
|
+
repo-cleaner --owner KazooTTT
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Keys:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
up/down move
|
|
42
|
+
space select repository
|
|
43
|
+
f toggle forks-only
|
|
44
|
+
s toggle sort oldest/newest
|
|
45
|
+
/ search
|
|
46
|
+
a archive mode
|
|
47
|
+
d delete mode
|
|
48
|
+
enter review selected repositories
|
|
49
|
+
esc cancel review
|
|
50
|
+
q quit
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm install
|
|
57
|
+
npm run dev
|
|
58
|
+
npm test
|
|
59
|
+
npm run typecheck
|
|
60
|
+
npm run build
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Release
|
|
64
|
+
|
|
65
|
+
Releases are created locally with `bumpp` and published by GitHub Actions when a `v*` tag is pushed.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm run release
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The release script runs tests, typecheck, and build before `bumpp` creates the version commit and tag.
|
|
72
|
+
|
|
73
|
+
## npm trusted publishing setup
|
|
74
|
+
|
|
75
|
+
This project is configured for npm trusted publishing with provenance. No `NPM_TOKEN` secret is required.
|
|
76
|
+
|
|
77
|
+
Expected identities:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
GitHub owner: KazooTTT
|
|
81
|
+
GitHub repository: github-repo-cleaner
|
|
82
|
+
npm package: @kazoottt/github-repo-cleaner
|
|
83
|
+
Workflow filename: publish.yml
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Setup steps:
|
|
87
|
+
|
|
88
|
+
1. Push this repository to GitHub as `KazooTTT/github-repo-cleaner`.
|
|
89
|
+
2. Sign in to npm as the owner of the `@kazoottt` scope.
|
|
90
|
+
3. Open the npm package settings for `@kazoottt/github-repo-cleaner`.
|
|
91
|
+
4. Add a trusted publisher with:
|
|
92
|
+
- Owner: `KazooTTT`
|
|
93
|
+
- Repository: `github-repo-cleaner`
|
|
94
|
+
- Workflow filename: `publish.yml`
|
|
95
|
+
- Environment name: leave empty unless the workflow is changed to use one
|
|
96
|
+
5. For the first publish only, run `npm publish --access public` locally to create the package.
|
|
97
|
+
6. Add the trusted publisher in the package settings.
|
|
98
|
+
7. For later releases, run `npm run release` locally.
|
|
99
|
+
8. Confirm the pushed tag triggers the `Publish` workflow in GitHub Actions.
|
|
100
|
+
|
|
101
|
+
After trusted publishing is configured, the publish workflow runs:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm ci
|
|
105
|
+
npm test
|
|
106
|
+
npm run typecheck
|
|
107
|
+
npm run build
|
|
108
|
+
npm publish --provenance --access public
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Issue Tracker
|
|
112
|
+
|
|
113
|
+
Local implementation issues live under `.scratch/issues/`.
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export class AuthError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function getGhToken(runCommand = execFileAsync) {
|
|
12
|
+
try {
|
|
13
|
+
await runCommand("gh", ["auth", "status", "--hostname", "github.com"]);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const nodeError = error;
|
|
17
|
+
if (nodeError.code === "ENOENT") {
|
|
18
|
+
throw new AuthError("missing-gh", "GitHub CLI is not installed. Install gh, then run gh auth login.");
|
|
19
|
+
}
|
|
20
|
+
throw new AuthError("not-authenticated", "GitHub CLI is not authenticated. Run gh auth login.");
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await runCommand("gh", ["auth", "token", "--hostname", "github.com"]);
|
|
24
|
+
const token = stdout.trim();
|
|
25
|
+
if (!token) {
|
|
26
|
+
throw new AuthError("token-unavailable", "GitHub CLI did not return a token.");
|
|
27
|
+
}
|
|
28
|
+
return token;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof AuthError)
|
|
32
|
+
throw error;
|
|
33
|
+
throw new AuthError("token-unavailable", "Unable to read a GitHub token from gh. Try gh auth refresh -h github.com -s delete_repo.");
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { getGhToken, AuthError } from "./auth.js";
|
|
5
|
+
import { OctokitGitHubApi } from "./github.js";
|
|
6
|
+
import { App } from "./ui.js";
|
|
7
|
+
function parseOwner(argv) {
|
|
8
|
+
const ownerIndex = argv.indexOf("--owner");
|
|
9
|
+
if (ownerIndex === -1)
|
|
10
|
+
return undefined;
|
|
11
|
+
return argv[ownerIndex + 1];
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const token = await getGhToken();
|
|
15
|
+
const api = new OctokitGitHubApi(token);
|
|
16
|
+
render(_jsx(App, { api: api, owner: parseOwner(process.argv.slice(2)) }));
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error instanceof AuthError) {
|
|
20
|
+
console.error(error.message);
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/domain.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function sortByPushedAt(repositories, direction = "oldest") {
|
|
2
|
+
return [...repositories].sort((a, b) => {
|
|
3
|
+
const aTime = a.pushedAt ? Date.parse(a.pushedAt) : 0;
|
|
4
|
+
const bTime = b.pushedAt ? Date.parse(b.pushedAt) : 0;
|
|
5
|
+
return direction === "oldest" ? aTime - bTime : bTime - aTime;
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
export function formatPushedAt(value) {
|
|
9
|
+
if (!value)
|
|
10
|
+
return "never";
|
|
11
|
+
const date = new Date(value);
|
|
12
|
+
if (Number.isNaN(date.getTime()))
|
|
13
|
+
return value;
|
|
14
|
+
const formatter = new Intl.DateTimeFormat("sv-SE", {
|
|
15
|
+
year: "numeric",
|
|
16
|
+
month: "2-digit",
|
|
17
|
+
day: "2-digit",
|
|
18
|
+
hour: "2-digit",
|
|
19
|
+
minute: "2-digit",
|
|
20
|
+
hour12: false
|
|
21
|
+
});
|
|
22
|
+
return formatter.format(date);
|
|
23
|
+
}
|
|
24
|
+
export function filterRepositories(repositories, options) {
|
|
25
|
+
const normalizedQuery = options.query.trim().toLowerCase();
|
|
26
|
+
return repositories.filter((repository) => {
|
|
27
|
+
if (options.forksOnly && !repository.fork)
|
|
28
|
+
return false;
|
|
29
|
+
if (!normalizedQuery)
|
|
30
|
+
return true;
|
|
31
|
+
return repository.fullName.toLowerCase().includes(normalizedQuery);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function baseRiskFlags(repositories) {
|
|
35
|
+
return repositories.flatMap((repository) => {
|
|
36
|
+
const flags = [];
|
|
37
|
+
if (repository.private) {
|
|
38
|
+
flags.push({
|
|
39
|
+
kind: "private",
|
|
40
|
+
repoFullName: repository.fullName,
|
|
41
|
+
message: `${repository.fullName} is private`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (!repository.fork) {
|
|
45
|
+
flags.push({
|
|
46
|
+
kind: "non-fork",
|
|
47
|
+
repoFullName: repository.fullName,
|
|
48
|
+
message: `${repository.fullName} is not a fork`
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (repository.archived) {
|
|
52
|
+
flags.push({
|
|
53
|
+
kind: "archived",
|
|
54
|
+
repoFullName: repository.fullName,
|
|
55
|
+
message: `${repository.fullName} is already archived`
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (repository.fork && repository.upstreamMissing) {
|
|
59
|
+
flags.push({
|
|
60
|
+
kind: "upstream-missing",
|
|
61
|
+
repoFullName: repository.fullName,
|
|
62
|
+
message: `${repository.fullName} has missing upstream metadata`
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return flags;
|
|
66
|
+
});
|
|
67
|
+
}
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Octokit } from "@octokit/rest";
|
|
2
|
+
export class OctokitGitHubApi {
|
|
3
|
+
octokit;
|
|
4
|
+
constructor(token) {
|
|
5
|
+
this.octokit = new Octokit({ auth: token, userAgent: "github-repo-cleaner" });
|
|
6
|
+
}
|
|
7
|
+
async getDefaultOwner() {
|
|
8
|
+
const { data } = await this.octokit.rest.users.getAuthenticated();
|
|
9
|
+
return data.login;
|
|
10
|
+
}
|
|
11
|
+
async listRepositories(owner) {
|
|
12
|
+
const defaultOwner = await this.getDefaultOwner();
|
|
13
|
+
const activeOwner = owner ?? defaultOwner;
|
|
14
|
+
const repositories = !owner || activeOwner.toLowerCase() === defaultOwner.toLowerCase()
|
|
15
|
+
? await this.octokit.paginate(this.octokit.rest.repos.listForAuthenticatedUser, {
|
|
16
|
+
affiliation: "owner",
|
|
17
|
+
per_page: 100,
|
|
18
|
+
sort: "pushed",
|
|
19
|
+
direction: "desc"
|
|
20
|
+
})
|
|
21
|
+
: await this.listForExplicitOwner(activeOwner);
|
|
22
|
+
return {
|
|
23
|
+
owner: activeOwner,
|
|
24
|
+
repositories: repositories
|
|
25
|
+
.filter((repository) => repository.owner.login.toLowerCase() === activeOwner.toLowerCase())
|
|
26
|
+
.map(toRepositorySummary)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async countOpenPullRequests(repository) {
|
|
30
|
+
const { data } = await this.octokit.rest.search.issuesAndPullRequests({
|
|
31
|
+
q: `repo:${repository.fullName} is:pr is:open`,
|
|
32
|
+
per_page: 1
|
|
33
|
+
});
|
|
34
|
+
return data.total_count;
|
|
35
|
+
}
|
|
36
|
+
async archiveRepository(repository) {
|
|
37
|
+
return this.execute("archive", repository, async () => {
|
|
38
|
+
const response = await this.octokit.rest.repos.update({
|
|
39
|
+
owner: repository.owner,
|
|
40
|
+
repo: repository.name,
|
|
41
|
+
archived: true
|
|
42
|
+
});
|
|
43
|
+
return response.status;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async deleteRepository(repository) {
|
|
47
|
+
return this.execute("delete", repository, async () => {
|
|
48
|
+
const response = await this.octokit.rest.repos.delete({
|
|
49
|
+
owner: repository.owner,
|
|
50
|
+
repo: repository.name
|
|
51
|
+
});
|
|
52
|
+
return response.status;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async listForExplicitOwner(owner) {
|
|
56
|
+
const { data: user } = await this.octokit.rest.users.getByUsername({ username: owner });
|
|
57
|
+
if (user.type === "Organization") {
|
|
58
|
+
return this.octokit.paginate(this.octokit.rest.repos.listForOrg, {
|
|
59
|
+
org: owner,
|
|
60
|
+
type: "all",
|
|
61
|
+
per_page: 100,
|
|
62
|
+
sort: "pushed",
|
|
63
|
+
direction: "desc"
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return this.octokit.paginate(this.octokit.rest.repos.listForUser, {
|
|
67
|
+
username: owner,
|
|
68
|
+
type: "owner",
|
|
69
|
+
per_page: 100,
|
|
70
|
+
sort: "pushed",
|
|
71
|
+
direction: "desc"
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async execute(action, repository, call) {
|
|
75
|
+
try {
|
|
76
|
+
const httpStatus = await call();
|
|
77
|
+
return { fullName: repository.fullName, action, status: "success", httpStatus };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const apiError = error;
|
|
81
|
+
return {
|
|
82
|
+
fullName: repository.fullName,
|
|
83
|
+
action,
|
|
84
|
+
status: "failed",
|
|
85
|
+
httpStatus: apiError.status,
|
|
86
|
+
message: formatGitHubApiErrorMessage(apiError)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function toRepositorySummary(repository) {
|
|
92
|
+
const parent = repository.parent;
|
|
93
|
+
return {
|
|
94
|
+
id: repository.id,
|
|
95
|
+
name: repository.name,
|
|
96
|
+
fullName: repository.full_name,
|
|
97
|
+
owner: repository.owner.login,
|
|
98
|
+
private: repository.private ?? false,
|
|
99
|
+
fork: repository.fork ?? false,
|
|
100
|
+
archived: repository.archived ?? false,
|
|
101
|
+
pushedAt: repository.pushed_at ?? null,
|
|
102
|
+
upstreamFullName: parent?.full_name ?? undefined,
|
|
103
|
+
upstreamMissing: (repository.fork ?? false) && !parent?.full_name
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export async function computeRiskFlags(api, repositories, mode) {
|
|
107
|
+
const { baseRiskFlags } = await import("./domain.js");
|
|
108
|
+
const flags = baseRiskFlags(repositories);
|
|
109
|
+
if (mode !== "delete")
|
|
110
|
+
return flags;
|
|
111
|
+
const openPrCounts = await Promise.all(repositories
|
|
112
|
+
.filter((repository) => repository.fork)
|
|
113
|
+
.map(async (repository) => ({ repository, count: await api.countOpenPullRequests(repository) })));
|
|
114
|
+
for (const { repository, count } of openPrCounts) {
|
|
115
|
+
if (count > 0) {
|
|
116
|
+
flags.push({
|
|
117
|
+
kind: "open-prs",
|
|
118
|
+
repoFullName: repository.fullName,
|
|
119
|
+
message: `${repository.fullName} has ${count} open pull request${count === 1 ? "" : "s"}`
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return flags;
|
|
124
|
+
}
|
|
125
|
+
export function formatGitHubApiErrorMessage(error) {
|
|
126
|
+
const permissionHint = error.status === 403
|
|
127
|
+
? " Try gh auth refresh -h github.com -s delete_repo if this is a permission issue."
|
|
128
|
+
: "";
|
|
129
|
+
return `${error.message ?? "Unknown GitHub API error"}${permissionHint}`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function defaultSessionLogDirectory() {
|
|
5
|
+
return join(homedir(), ".local", "state", "github-repo-cleaner", "sessions");
|
|
6
|
+
}
|
|
7
|
+
export async function writeSessionLog(options) {
|
|
8
|
+
const directory = options.directory ?? defaultSessionLogDirectory();
|
|
9
|
+
await mkdir(directory, { recursive: true });
|
|
10
|
+
const resultByName = new Map(options.results.map((result) => [result.fullName, result]));
|
|
11
|
+
const log = {
|
|
12
|
+
owner: options.owner,
|
|
13
|
+
mode: options.mode,
|
|
14
|
+
startedAt: options.startedAt.toISOString(),
|
|
15
|
+
completedAt: options.completedAt.toISOString(),
|
|
16
|
+
items: options.repositories.map((repository) => {
|
|
17
|
+
const result = resultByName.get(repository.fullName);
|
|
18
|
+
return {
|
|
19
|
+
fullName: repository.fullName,
|
|
20
|
+
fork: repository.fork,
|
|
21
|
+
private: repository.private,
|
|
22
|
+
archived: repository.archived,
|
|
23
|
+
action: options.mode,
|
|
24
|
+
status: result?.status ?? "failed",
|
|
25
|
+
httpStatus: result?.httpStatus,
|
|
26
|
+
message: result?.message
|
|
27
|
+
};
|
|
28
|
+
})
|
|
29
|
+
};
|
|
30
|
+
const fileName = `${options.startedAt.toISOString().replace(/[:.]/g, "-")}.json`;
|
|
31
|
+
const path = join(directory, fileName);
|
|
32
|
+
await writeFile(path, `${JSON.stringify(log, null, 2)}\n`, "utf8");
|
|
33
|
+
return path;
|
|
34
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import { computeRiskFlags } from "./github.js";
|
|
5
|
+
import { filterRepositories, formatPushedAt, sortByPushedAt } from "./domain.js";
|
|
6
|
+
import { writeSessionLog } from "./session-log.js";
|
|
7
|
+
export function App({ api, owner: requestedOwner, writeLog = writeSessionLog }) {
|
|
8
|
+
const { exit } = useApp();
|
|
9
|
+
const [view, setView] = useState("loading");
|
|
10
|
+
const [owner, setOwner] = useState(requestedOwner ?? "");
|
|
11
|
+
const [repositories, setRepositories] = useState([]);
|
|
12
|
+
const [cursor, setCursor] = useState(0);
|
|
13
|
+
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
14
|
+
const [forksOnly, setForksOnly] = useState(false);
|
|
15
|
+
const [sortDirection, setSortDirection] = useState("oldest");
|
|
16
|
+
const [query, setQuery] = useState("");
|
|
17
|
+
const [searching, setSearching] = useState(false);
|
|
18
|
+
const [mode, setMode] = useState("archive");
|
|
19
|
+
const [riskFlags, setRiskFlags] = useState([]);
|
|
20
|
+
const [riskFlagsLoading, setRiskFlagsLoading] = useState(false);
|
|
21
|
+
const [results, setResults] = useState([]);
|
|
22
|
+
const [logPath, setLogPath] = useState();
|
|
23
|
+
const [error, setError] = useState();
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let active = true;
|
|
26
|
+
api
|
|
27
|
+
.listRepositories(requestedOwner)
|
|
28
|
+
.then((response) => {
|
|
29
|
+
if (!active)
|
|
30
|
+
return;
|
|
31
|
+
setOwner(response.owner);
|
|
32
|
+
setRepositories(response.repositories);
|
|
33
|
+
setView("list");
|
|
34
|
+
})
|
|
35
|
+
.catch((loadError) => {
|
|
36
|
+
if (!active)
|
|
37
|
+
return;
|
|
38
|
+
const message = loadError instanceof Error ? loadError.message : "Unable to load repositories";
|
|
39
|
+
setError(message);
|
|
40
|
+
setView("error");
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
active = false;
|
|
44
|
+
};
|
|
45
|
+
}, [api, requestedOwner]);
|
|
46
|
+
const visibleRepositories = useMemo(() => filterRepositories(sortByPushedAt(repositories, sortDirection), { query, forksOnly }), [repositories, sortDirection, query, forksOnly]);
|
|
47
|
+
const selectedRepositories = useMemo(() => repositories.filter((repository) => selectedIds.has(repository.id)), [repositories, selectedIds]);
|
|
48
|
+
const selectedRepositoriesRef = useRef([]);
|
|
49
|
+
const viewRef = useRef(view);
|
|
50
|
+
const modeRef = useRef(mode);
|
|
51
|
+
const ownerRef = useRef(owner);
|
|
52
|
+
const riskFlagsLoadingRef = useRef(riskFlagsLoading);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
selectedRepositoriesRef.current = selectedRepositories;
|
|
55
|
+
viewRef.current = view;
|
|
56
|
+
modeRef.current = mode;
|
|
57
|
+
ownerRef.current = owner;
|
|
58
|
+
riskFlagsLoadingRef.current = riskFlagsLoading;
|
|
59
|
+
}, [selectedRepositories, view, mode, owner, riskFlagsLoading]);
|
|
60
|
+
useInput((input, key) => {
|
|
61
|
+
if (input === "q") {
|
|
62
|
+
exit();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const isReturn = key.return || input === "\r" || input === "\n";
|
|
66
|
+
if (viewRef.current === "list") {
|
|
67
|
+
if (searching) {
|
|
68
|
+
if (isReturn) {
|
|
69
|
+
setSearching(false);
|
|
70
|
+
}
|
|
71
|
+
else if (key.backspace || key.delete) {
|
|
72
|
+
setQuery((current) => current.slice(0, -1));
|
|
73
|
+
}
|
|
74
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
75
|
+
setQuery((current) => `${current}${input}`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (key.upArrow)
|
|
80
|
+
setCursor((current) => Math.max(0, current - 1));
|
|
81
|
+
if (key.downArrow)
|
|
82
|
+
setCursor((current) => Math.min(visibleRepositories.length - 1, current + 1));
|
|
83
|
+
if (input === "f")
|
|
84
|
+
setForksOnly((current) => !current);
|
|
85
|
+
if (input === "s")
|
|
86
|
+
setSortDirection((current) => current === "oldest" ? "newest" : "oldest");
|
|
87
|
+
if (input === "/")
|
|
88
|
+
setSearching(true);
|
|
89
|
+
if (input === "a")
|
|
90
|
+
setMode("archive");
|
|
91
|
+
if (input === "d")
|
|
92
|
+
setMode("delete");
|
|
93
|
+
if (input === " " && visibleRepositories[cursor]) {
|
|
94
|
+
const repository = visibleRepositories[cursor];
|
|
95
|
+
setSelectedIds((current) => {
|
|
96
|
+
const next = new Set(current);
|
|
97
|
+
if (next.has(repository.id))
|
|
98
|
+
next.delete(repository.id);
|
|
99
|
+
else
|
|
100
|
+
next.add(repository.id);
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (isReturn && selectedRepositoriesRef.current.length > 0) {
|
|
105
|
+
setRiskFlags([]);
|
|
106
|
+
setRiskFlagsLoading(true);
|
|
107
|
+
setView("review");
|
|
108
|
+
void computeRiskFlags(api, selectedRepositoriesRef.current, modeRef.current).then((flags) => {
|
|
109
|
+
setRiskFlags(flags);
|
|
110
|
+
setRiskFlagsLoading(false);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (viewRef.current === "review") {
|
|
116
|
+
if (key.escape) {
|
|
117
|
+
setView("list");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (isReturn && !riskFlagsLoadingRef.current) {
|
|
121
|
+
void execute();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
async function execute() {
|
|
126
|
+
const startedAt = new Date();
|
|
127
|
+
setView("executing");
|
|
128
|
+
const operationResults = [];
|
|
129
|
+
const repositoriesToExecute = selectedRepositoriesRef.current;
|
|
130
|
+
const modeToExecute = modeRef.current;
|
|
131
|
+
for (const repository of repositoriesToExecute) {
|
|
132
|
+
const result = modeToExecute === "archive" ? await api.archiveRepository(repository) : await api.deleteRepository(repository);
|
|
133
|
+
operationResults.push(result);
|
|
134
|
+
setResults([...operationResults]);
|
|
135
|
+
}
|
|
136
|
+
const completedAt = new Date();
|
|
137
|
+
const path = await writeLog({
|
|
138
|
+
owner: ownerRef.current,
|
|
139
|
+
mode: modeToExecute,
|
|
140
|
+
startedAt,
|
|
141
|
+
completedAt,
|
|
142
|
+
repositories: repositoriesToExecute,
|
|
143
|
+
results: operationResults
|
|
144
|
+
});
|
|
145
|
+
setLogPath(path);
|
|
146
|
+
setResults(operationResults);
|
|
147
|
+
setView("result");
|
|
148
|
+
}
|
|
149
|
+
if (view === "loading")
|
|
150
|
+
return _jsx(Text, { children: "Loading repositories..." });
|
|
151
|
+
if (view === "error")
|
|
152
|
+
return _jsx(Text, { color: "red", children: error });
|
|
153
|
+
if (view === "review")
|
|
154
|
+
return _jsx(ReviewView, { mode: mode, repositories: selectedRepositories, riskFlags: riskFlags, loading: riskFlagsLoading });
|
|
155
|
+
if (view === "executing")
|
|
156
|
+
return _jsxs(Text, { children: ["Executing ", mode, " for ", selectedRepositories.length, " repositories..."] });
|
|
157
|
+
if (view === "result")
|
|
158
|
+
return _jsx(ResultView, { results: results, logPath: logPath });
|
|
159
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["github-repo-cleaner owner:", owner, " mode:", mode, " sort:", sortDirection, " selected:", selectedIds.size] }), _jsxs(Text, { children: ["[up/down] move [space] select [f] forks only:", forksOnly ? "on" : "off", " [s] sort [/] search [a] archive [d] delete [enter] review [q] quit"] }), _jsxs(Text, { children: ["search:", searching ? `${query}_` : query || "-"] }), visibleRepositories.slice(Math.max(0, cursor - 5), cursor + 15).map((repository) => {
|
|
160
|
+
const active = visibleRepositories[cursor]?.id === repository.id;
|
|
161
|
+
const selected = selectedIds.has(repository.id);
|
|
162
|
+
return (_jsxs(Text, { color: active ? "cyan" : undefined, children: [active ? ">" : " ", " ", selected ? "[x]" : "[ ]", " ", repository.fork ? "fork" : "own ", " ", repository.fullName, " pushed:", formatPushedAt(repository.pushedAt), " ", repository.fork ? `upstream:${repository.upstreamFullName ?? "missing"}` : ""] }, repository.id));
|
|
163
|
+
}), visibleRepositories.length === 0 ? _jsx(Text, { children: "No repositories match the current filters." }) : null] }));
|
|
164
|
+
}
|
|
165
|
+
function ReviewView({ mode, repositories, riskFlags, loading }) {
|
|
166
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [mode.toUpperCase(), " ", repositories.length, " repositories"] }), repositories.slice(0, 12).map((repository) => _jsxs(Text, { children: ["- ", repository.fullName] }, repository.id)), repositories.length > 12 ? _jsxs(Text, { children: ["... ", repositories.length - 12, " more"] }) : null, _jsx(Text, { children: "Risk flags:" }), loading ? _jsx(Text, { children: "Checking risks..." }) : riskFlags.length === 0 ? _jsx(Text, { children: "None" }) : riskFlags.map((flag) => _jsxs(Text, { color: "yellow", children: ["! ", flag.message] }, `${flag.kind}:${flag.repoFullName}`)), _jsx(Text, { children: loading ? "[enter] waiting for risk check [esc] cancel" : "[enter] confirm [esc] cancel" })] }));
|
|
167
|
+
}
|
|
168
|
+
function ResultView({ results, logPath }) {
|
|
169
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Results" }), results.map((result) => (_jsxs(Text, { color: result.status === "success" ? "green" : "red", children: [result.status === "success" ? "ok" : "fail", " ", result.action, " ", result.fullName, " ", result.httpStatus ?? "", " ", result.message ?? ""] }, result.fullName))), logPath ? _jsxs(Text, { children: ["Log: ", logPath] }) : null] }));
|
|
170
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kazoottt/github-repo-cleaner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local TUI for cleaning GitHub repositories with gh authentication.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"github-repo-cleaner": "dist/cli.js",
|
|
8
|
+
"repo-cleaner": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "tsx src/cli.tsx",
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest",
|
|
16
|
+
"prepack": "npm run build",
|
|
17
|
+
"release": "npm test && npm run typecheck && npm run build && bumpp --commit \"chore(release): v%s\" --tag \"v%s\" --push"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@octokit/rest": "latest",
|
|
21
|
+
"ink": "latest",
|
|
22
|
+
"react": "latest"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "latest",
|
|
26
|
+
"@types/react": "latest",
|
|
27
|
+
"bumpp": "^11.1.0",
|
|
28
|
+
"ink-testing-library": "latest",
|
|
29
|
+
"tsx": "latest",
|
|
30
|
+
"typescript": "latest",
|
|
31
|
+
"vitest": "latest"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/KazooTTT/github-repo-cleaner.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/KazooTTT/github-repo-cleaner#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/KazooTTT/github-repo-cleaner/issues"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|