Skip to content

Commit e1de32f

Browse files
authored
Merge pull request #26 from LonoxX/develop
feat: support creation and update of closed issues and PRs
2 parents 30c2c83 + 92d987f commit e1de32f

22 files changed

Lines changed: 3403 additions & 2722 deletions

package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,19 @@
1919
"author": "LonoxX",
2020
"license": "MIT",
2121
"devDependencies": {
22-
"@types/node": "^24.0.3",
23-
"@typescript-eslint/eslint-plugin": "^8.34.1",
24-
"@typescript-eslint/parser": "^8.34.1",
22+
"@types/node": "^24.9.1",
23+
"@typescript-eslint/eslint-plugin": "^8.46.2",
24+
"@typescript-eslint/parser": "^8.46.2",
2525
"builtin-modules": "^5.0.0",
26-
"esbuild": "^0.25.5",
27-
"eslint": "^9.29.0",
26+
"esbuild": "^0.25.11",
27+
"eslint": "^9.38.0",
2828
"obsidian": "latest",
2929
"tslib": "^2.8.1",
30-
"typescript": "^5.8.3"
30+
"typescript": "^5.9.3"
3131
},
3232
"dependencies": {
3333
"date-fns": "^4.1.0",
34-
"octokit": "^5.0.3",
35-
"prettier": "^3.5.3"
34+
"octokit": "^5.0.4",
35+
"prettier": "^3.6.2"
3636
}
3737
}

src/cleanup-manager.ts

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import { App, TFile, TFolder } from "obsidian";
2+
import { GitHubTrackerSettings, RepositoryTracking } from "./types";
3+
import { extractProperties } from "./util/properties";
4+
import { NoticeManager } from "./notice-manager";
5+
import { extractNumberFromFilename } from "./util/templateUtils";
6+
import { FolderPathManager } from "./folder-path-manager";
7+
8+
export class CleanupManager {
9+
private folderPathManager: FolderPathManager;
10+
11+
constructor(
12+
private app: App,
13+
private settings: GitHubTrackerSettings,
14+
private noticeManager: NoticeManager,
15+
) {
16+
this.folderPathManager = new FolderPathManager();
17+
}
18+
19+
/**
20+
* Cleanup deleted issues - remove files for issues that are no longer tracked
21+
*/
22+
public async cleanupDeletedIssues(
23+
repo: RepositoryTracking,
24+
ownerCleaned: string,
25+
repoCleaned: string,
26+
allIssuesIncludingRecentlyClosed: any[],
27+
): Promise<void> {
28+
const issueFolderPath = this.folderPathManager.getIssueFolderPath(repo, ownerCleaned, repoCleaned);
29+
const repoFolder = this.app.vault.getAbstractFileByPath(issueFolderPath);
30+
31+
if (repoFolder) {
32+
const files = this.app.vault
33+
.getFiles()
34+
.filter(
35+
(file) =>
36+
file.path.startsWith(`${issueFolderPath}/`) && file.extension === "md",
37+
);
38+
39+
for (const file of files) {
40+
// Try to get number from frontmatter first (most reliable)
41+
const properties = extractProperties(this.app, file);
42+
let fileNumberString: string | null = null;
43+
44+
if (properties.number) {
45+
fileNumberString = properties.number.toString();
46+
} else {
47+
// Fallback: try to extract from filename
48+
fileNumberString = extractNumberFromFilename(
49+
file.name,
50+
repo.issueNoteTemplate || "Issue - {number}"
51+
);
52+
}
53+
54+
if (!fileNumberString) {
55+
// If we can't determine the issue number, log a warning but skip
56+
this.noticeManager.debug(
57+
`Could not determine issue number for file: ${file.name}. Consider adding a 'number' property to the frontmatter.`
58+
);
59+
continue;
60+
}
61+
62+
const correspondingIssue =
63+
allIssuesIncludingRecentlyClosed.find(
64+
(issue: any) =>
65+
issue.number.toString() === fileNumberString,
66+
);
67+
68+
let shouldDelete = false;
69+
let deleteReason = "";
70+
71+
if (correspondingIssue) {
72+
if (correspondingIssue.state === "closed" && correspondingIssue.closed_at) {
73+
// Check if issue has been closed longer than the configured days
74+
const closedDate = new Date(correspondingIssue.closed_at);
75+
const cutoffDate = new Date();
76+
cutoffDate.setDate(cutoffDate.getDate() - this.settings.cleanupClosedIssuesDays);
77+
78+
if (closedDate < cutoffDate) {
79+
shouldDelete = true;
80+
const daysClosed = Math.floor((Date.now() - closedDate.getTime()) / (1000 * 60 * 60 * 24));
81+
deleteReason = `Deleted issue ${fileNumberString} from ${repo.repository} (closed ${daysClosed} days ago, threshold: ${this.settings.cleanupClosedIssuesDays} days)`;
82+
}
83+
}
84+
} else {
85+
shouldDelete = true;
86+
deleteReason = `Deleted issue ${fileNumberString} from ${repo.repository} as it's no longer tracked (closed > ${this.settings.cleanupClosedIssuesDays} days or deleted)`;
87+
}
88+
89+
if (shouldDelete) {
90+
const allowDelete = properties.allowDelete
91+
? String(properties.allowDelete)
92+
.toLowerCase()
93+
.replace('"', "") === "true"
94+
: repo.allowDeleteIssue;
95+
96+
if (allowDelete) {
97+
await this.app.fileManager.trashFile(file);
98+
this.noticeManager.info(deleteReason);
99+
}
100+
}
101+
}
102+
}
103+
}
104+
105+
/**
106+
* Cleanup deleted pull requests - remove files for PRs that are no longer tracked
107+
*/
108+
public async cleanupDeletedPullRequests(
109+
repo: RepositoryTracking,
110+
ownerCleaned: string,
111+
repoCleaned: string,
112+
allPullRequestsIncludingRecentlyClosed: any[],
113+
): Promise<void> {
114+
const pullRequestFolderPath = this.folderPathManager.getPullRequestFolderPath(repo, ownerCleaned, repoCleaned);
115+
const repoFolder = this.app.vault.getAbstractFileByPath(pullRequestFolderPath);
116+
117+
if (repoFolder) {
118+
const files = this.app.vault
119+
.getFiles()
120+
.filter(
121+
(file) =>
122+
file.path.startsWith(`${pullRequestFolderPath}/`) && file.extension === "md",
123+
);
124+
125+
for (const file of files) {
126+
// Try to get number from frontmatter first (most reliable)
127+
const properties = extractProperties(this.app, file);
128+
let fileNumberString: string | null = null;
129+
130+
if (properties.number) {
131+
fileNumberString = properties.number.toString();
132+
} else {
133+
// Fallback: try to extract from filename
134+
fileNumberString = extractNumberFromFilename(
135+
file.name,
136+
repo.pullRequestNoteTemplate || "Pull Request - {number}"
137+
);
138+
}
139+
140+
if (!fileNumberString) {
141+
// If we can't determine the PR number, log a warning but skip
142+
this.noticeManager.debug(
143+
`Could not determine PR number for file: ${file.name}. Consider adding a 'number' property to the frontmatter.`
144+
);
145+
continue;
146+
}
147+
148+
const correspondingPR =
149+
allPullRequestsIncludingRecentlyClosed.find(
150+
(pr: any) => pr.number.toString() === fileNumberString,
151+
);
152+
153+
let shouldDelete = false;
154+
let deleteReason = "";
155+
156+
if (correspondingPR) {
157+
if (correspondingPR.state === "closed" && correspondingPR.closed_at) {
158+
// Check if PR has been closed longer than the configured days
159+
const closedDate = new Date(correspondingPR.closed_at);
160+
const cutoffDate = new Date();
161+
cutoffDate.setDate(cutoffDate.getDate() - this.settings.cleanupClosedIssuesDays);
162+
163+
if (closedDate < cutoffDate) {
164+
shouldDelete = true;
165+
const daysClosed = Math.floor((Date.now() - closedDate.getTime()) / (1000 * 60 * 60 * 24));
166+
deleteReason = `Deleted pull request ${fileNumberString} from ${repo.repository} (closed ${daysClosed} days ago, threshold: ${this.settings.cleanupClosedIssuesDays} days)`;
167+
}
168+
}
169+
} else {
170+
shouldDelete = true;
171+
deleteReason = `Deleted pull request ${fileNumberString} from ${repo.repository} as it's no longer tracked (closed > ${this.settings.cleanupClosedIssuesDays} days or deleted)`;
172+
}
173+
174+
if (shouldDelete) {
175+
const allowDelete = properties.allowDelete
176+
? String(properties.allowDelete)
177+
.toLowerCase()
178+
.replace('"', "") === "true"
179+
: repo.allowDeletePullRequest;
180+
181+
if (allowDelete) {
182+
await this.app.fileManager.trashFile(file);
183+
this.noticeManager.info(deleteReason);
184+
}
185+
}
186+
}
187+
}
188+
}
189+
190+
/**
191+
* Cleanup empty issue folder and its parent folders
192+
*/
193+
public async cleanupEmptyIssueFolder(
194+
repo: RepositoryTracking,
195+
issueFolder: string,
196+
ownerCleaned: string,
197+
): Promise<void> {
198+
const issueFolderContent =
199+
this.app.vault.getAbstractFileByPath(issueFolder);
200+
201+
if (issueFolderContent instanceof TFolder) {
202+
const files = issueFolderContent.children;
203+
204+
if (!repo.trackIssues) {
205+
for (const file of files) {
206+
if (file instanceof TFile) {
207+
// Use Obsidian's MetadataCache to get frontmatter
208+
const properties = extractProperties(this.app, file);
209+
const allowDelete = properties.allowDelete
210+
? String(properties.allowDelete)
211+
.toLowerCase()
212+
.replace('"', "") === "true"
213+
: false;
214+
215+
if (allowDelete) {
216+
await this.app.fileManager.trashFile(file);
217+
this.noticeManager.debug(
218+
`Deleted file ${file.name} from untracked repo`,
219+
);
220+
files.splice(files.indexOf(file), 1);
221+
}
222+
}
223+
}
224+
}
225+
226+
// Only cleanup nested folder structure if not using custom folder
227+
if (!repo.useCustomIssueFolder || !repo.customIssueFolder || !repo.customIssueFolder.trim()) {
228+
if (files.length === 0) {
229+
this.noticeManager.info(
230+
`Deleting empty folder: ${issueFolder}`,
231+
);
232+
const folder =
233+
this.app.vault.getAbstractFileByPath(issueFolder);
234+
if (folder instanceof TFolder && folder.children.length === 0) {
235+
await this.app.fileManager.trashFile(folder);
236+
}
237+
}
238+
239+
const issueOwnerFolder = this.app.vault.getAbstractFileByPath(
240+
`${repo.issueFolder}/${ownerCleaned}`,
241+
);
242+
243+
if (issueOwnerFolder instanceof TFolder) {
244+
const files = issueOwnerFolder.children;
245+
if (files.length === 0) {
246+
this.noticeManager.info(
247+
`Deleting empty folder: ${issueOwnerFolder.path}`,
248+
);
249+
await this.app.fileManager.trashFile(issueOwnerFolder);
250+
}
251+
}
252+
}
253+
}
254+
}
255+
256+
/**
257+
* Cleanup empty pull request folder and its parent folders
258+
*/
259+
public async cleanupEmptyPullRequestFolder(
260+
repo: RepositoryTracking,
261+
pullRequestFolder: string,
262+
ownerCleaned: string,
263+
): Promise<void> {
264+
const pullRequestFolderContent =
265+
this.app.vault.getAbstractFileByPath(pullRequestFolder);
266+
267+
if (pullRequestFolderContent instanceof TFolder) {
268+
const files = pullRequestFolderContent.children;
269+
270+
if (!repo.trackPullRequest) {
271+
for (const file of files) {
272+
if (file instanceof TFile) {
273+
// Use Obsidian's MetadataCache to get frontmatter
274+
const properties = extractProperties(this.app, file);
275+
const allowDelete = properties.allowDelete
276+
? String(properties.allowDelete)
277+
.toLowerCase()
278+
.replace('"', "") === "true"
279+
: false;
280+
281+
if (allowDelete) {
282+
await this.app.fileManager.trashFile(file);
283+
this.noticeManager.debug(
284+
`Deleted file ${file.name} from untracked repo`,
285+
);
286+
files.splice(files.indexOf(file), 1);
287+
}
288+
}
289+
}
290+
}
291+
292+
// Only cleanup nested folder structure if not using custom folder
293+
if (!repo.useCustomPullRequestFolder || !repo.customPullRequestFolder || !repo.customPullRequestFolder.trim()) {
294+
if (files.length === 0) {
295+
this.noticeManager.info(
296+
`Deleting empty folder: ${pullRequestFolder}`,
297+
);
298+
const folder =
299+
this.app.vault.getAbstractFileByPath(pullRequestFolder);
300+
if (folder instanceof TFolder && folder.children.length === 0) {
301+
await this.app.fileManager.trashFile(folder);
302+
}
303+
}
304+
305+
const pullRequestOwnerFolder = this.app.vault.getAbstractFileByPath(
306+
`${repo.pullRequestFolder}/${ownerCleaned}`,
307+
);
308+
309+
if (pullRequestOwnerFolder instanceof TFolder) {
310+
const files = pullRequestOwnerFolder.children;
311+
if (files.length === 0) {
312+
this.noticeManager.info(
313+
`Deleting empty folder: ${pullRequestOwnerFolder.path}`,
314+
);
315+
await this.app.fileManager.trashFile(
316+
pullRequestOwnerFolder,
317+
);
318+
}
319+
}
320+
}
321+
}
322+
}
323+
}

0 commit comments

Comments
 (0)