Skip to content

Commit e6fe484

Browse files
committed
feat: Support for GitHub Sub-Issues #35
1 parent 7e6daca commit e6fe484

11 files changed

Lines changed: 418 additions & 8 deletions

src/content-generator.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export class ContentGenerator {
2222
comments: any[],
2323
settings: GitHubTrackerSettings,
2424
projectData?: ProjectData[],
25+
subIssues?: any[],
26+
parentIssue?: any,
2527
): Promise<string> {
2628
// Determine whether to escape hash tags (repo setting takes precedence if ignoreGlobalSettings is true)
2729
const shouldEscapeHashTags = repo.ignoreGlobalSettings ? repo.escapeHashTags : settings.escapeHashTags;
@@ -37,7 +39,9 @@ export class ContentGenerator {
3739
settings.dateFormat,
3840
settings.escapeMode,
3941
shouldEscapeHashTags,
40-
projectData
42+
projectData,
43+
subIssues,
44+
parentIssue
4145
);
4246
return processContentTemplate(templateContent, templateData, settings.dateFormat);
4347
}

src/file-manager.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export class FileManager {
2929
private app: App,
3030
private settings: GitHubTrackerSettings,
3131
private noticeManager: NoticeManager,
32-
gitHubClient: GitHubClient,
32+
private gitHubClient: GitHubClient,
3333
) {
3434
this.issueFileManager = new IssueFileManager(app, settings, noticeManager, gitHubClient);
3535
this.prFileManager = new PullRequestFileManager(app, settings, noticeManager, gitHubClient);
@@ -159,6 +159,29 @@ export class FileManager {
159159
const repository = this.extractRepositoryFromUrl(content.url) || `${project.owner}/unknown`;
160160
const projectData = this.convertFieldValuesToProjectData(project, status, item.fieldValues?.nodes || []);
161161

162+
// Fetch sub-issues and parent issue for template support (only if enabled for project)
163+
let subIssues: any[] = [];
164+
let parentIssue: any = null;
165+
166+
if (isIssue && project.includeSubIssues) {
167+
const [owner, repoName] = repository.split("/");
168+
if (owner && repoName) {
169+
subIssues = await this.gitHubClient.fetchSubIssues(owner, repoName, content.number);
170+
parentIssue = await this.gitHubClient.fetchParentIssue(owner, repoName, content.number);
171+
172+
// Enrich sub-issues with vault paths if they exist
173+
const noteTemplate = project.issueNoteTemplate || "Issue - {number} - {title}";
174+
subIssues = await this.fileHelpers.enrichSubIssuesWithVaultPaths(
175+
subIssues,
176+
folderPath,
177+
noteTemplate,
178+
repository,
179+
this.settings.dateFormat,
180+
this.settings.escapeMode
181+
);
182+
}
183+
}
184+
162185
const templateData = isIssue
163186
? createIssueTemplateData(
164187
this.convertToIssueFormat(content),
@@ -167,7 +190,9 @@ export class FileManager {
167190
this.settings.dateFormat,
168191
this.settings.escapeMode,
169192
this.settings.escapeHashTags,
170-
[projectData]
193+
[projectData],
194+
subIssues,
195+
parentIssue
171196
)
172197
: createPullRequestTemplateData(
173198
this.convertToPullRequestFormat(content),
@@ -193,7 +218,9 @@ export class FileManager {
193218
project,
194219
status,
195220
isIssue,
196-
item.fieldValues?.nodes || []
221+
item.fieldValues?.nodes || [],
222+
subIssues,
223+
parentIssue
197224
);
198225

199226
if (existingFile && existingFile instanceof TFile) {
@@ -226,6 +253,8 @@ export class FileManager {
226253
status: string,
227254
isIssue: boolean,
228255
fieldValues: any[],
256+
subIssues?: any[],
257+
parentIssue?: any,
229258
): Promise<string> {
230259
const shouldEscapeHashTags = this.settings.escapeHashTags;
231260

@@ -255,7 +284,9 @@ export class FileManager {
255284
this.settings.dateFormat,
256285
this.settings.escapeMode,
257286
shouldEscapeHashTags,
258-
[projectData]
287+
[projectData],
288+
subIssues,
289+
parentIssue
259290
)
260291
: createPullRequestTemplateData(
261292
this.convertToPullRequestFormat(content),
@@ -272,7 +303,7 @@ export class FileManager {
272303
}
273304

274305
// Fallback to default format
275-
return this.generateDefaultProjectItemContent(content, project, status, isIssue, fieldValues);
306+
return this.generateDefaultProjectItemContent(content, project, status, isIssue, fieldValues, subIssues, parentIssue);
276307
}
277308

278309
/**
@@ -284,6 +315,8 @@ export class FileManager {
284315
status: string,
285316
isIssue: boolean,
286317
fieldValues: any[],
318+
subIssues?: any[],
319+
parentIssue?: any,
287320
): string {
288321
const shouldEscapeHashTags = this.settings.escapeHashTags;
289322
const dateFormat = this.settings.dateFormat;

src/github-client.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,96 @@ export class GitHubClient {
10411041
}
10421042
}
10431043

1044+
/**
1045+
* Fetch sub-issues for an issue
1046+
* Uses the GitHub Sub-Issues API: GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues
1047+
*/
1048+
public async fetchSubIssues(
1049+
owner: string,
1050+
repo: string,
1051+
issueNumber: number,
1052+
): Promise<any[]> {
1053+
if (!this.octokit) {
1054+
return [];
1055+
}
1056+
1057+
try {
1058+
let allSubIssues: any[] = [];
1059+
let page = 1;
1060+
let hasMorePages = true;
1061+
1062+
while (hasMorePages) {
1063+
const response = await this.octokit.request(
1064+
'GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
1065+
{
1066+
owner,
1067+
repo,
1068+
issue_number: issueNumber,
1069+
per_page: 100,
1070+
page,
1071+
}
1072+
);
1073+
1074+
allSubIssues = [...allSubIssues, ...response.data];
1075+
hasMorePages = response.data.length === 100;
1076+
page++;
1077+
}
1078+
1079+
this.noticeManager.debug(
1080+
`Fetched ${allSubIssues.length} sub-issues for issue #${issueNumber}`,
1081+
);
1082+
return allSubIssues;
1083+
} catch (error: any) {
1084+
// 404 means no sub-issues or feature not available
1085+
if (error.status === 404) {
1086+
return [];
1087+
}
1088+
this.noticeManager.debug(
1089+
`Error fetching sub-issues for issue #${issueNumber}: ${error.message}`,
1090+
);
1091+
return [];
1092+
}
1093+
}
1094+
1095+
/**
1096+
* Fetch parent issue for a sub-issue
1097+
* Uses the GitHub Sub-Issues API: GET /repos/{owner}/{repo}/issues/{issue_number}/parent
1098+
*/
1099+
public async fetchParentIssue(
1100+
owner: string,
1101+
repo: string,
1102+
issueNumber: number,
1103+
): Promise<any | null> {
1104+
if (!this.octokit) {
1105+
return null;
1106+
}
1107+
1108+
try {
1109+
const response = await this.octokit.request(
1110+
'GET /repos/{owner}/{repo}/issues/{issue_number}/parent',
1111+
{
1112+
owner,
1113+
repo,
1114+
issue_number: issueNumber,
1115+
}
1116+
);
1117+
1118+
this.noticeManager.debug(
1119+
`Found parent issue #${response.data.number} for issue #${issueNumber}`,
1120+
);
1121+
return response.data;
1122+
} catch (error: any) {
1123+
// 404 means no parent issue
1124+
if (error.status === 404) {
1125+
return null;
1126+
}
1127+
this.noticeManager.debug(
1128+
`Error fetching parent issue for #${issueNumber}: ${error.message}`,
1129+
);
1130+
return null;
1131+
}
1132+
}
1133+
10441134
public dispose(): void {
10451135
this.octokit = null;
10461136
this.currentUser = "";

src/issue-file-manager.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,36 @@ export class IssueFileManager {
113113
);
114114
}
115115

116-
let content = await this.contentGenerator.createIssueContent(issue, repo, comments, this.settings);
116+
// Fetch sub-issues and parent issue for template support (only if enabled)
117+
let subIssues: any[] = [];
118+
let parentIssue: any = null;
119+
120+
if (repo.includeSubIssues) {
121+
subIssues = await this.gitHubClient.fetchSubIssues(owner, repoName, issue.number);
122+
parentIssue = await this.gitHubClient.fetchParentIssue(owner, repoName, issue.number);
123+
124+
// Enrich sub-issues with vault paths if they exist
125+
const issueFolder = this.folderPathManager.getIssueFolderPath(repo, owner, repoName);
126+
const noteTemplate = repo.issueNoteTemplate || "Issue - {number}";
127+
subIssues = await this.fileHelpers.enrichSubIssuesWithVaultPaths(
128+
subIssues,
129+
issueFolder,
130+
noteTemplate,
131+
repo.repository,
132+
this.settings.dateFormat,
133+
this.settings.escapeMode
134+
);
135+
}
136+
137+
let content = await this.contentGenerator.createIssueContent(
138+
issue,
139+
repo,
140+
comments,
141+
this.settings,
142+
undefined, // projectData
143+
subIssues,
144+
parentIssue
145+
);
117146

118147
if (file) {
119148
if (file instanceof TFile) {
@@ -146,6 +175,9 @@ export class IssueFileManager {
146175
repo,
147176
comments,
148177
this.settings,
178+
undefined, // projectData
179+
subIssues,
180+
parentIssue
149181
);
150182

151183
// Merge persist blocks back into new content

src/settings/project-renderer.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,19 @@ export class ProjectRenderer {
156156
});
157157
});
158158

159+
new Setting(issuesSettingsContainer)
160+
.setName("Include sub-issues")
161+
.setDesc("If enabled, sub-issues will be included in the generated files")
162+
.addToggle((toggle) =>
163+
toggle
164+
.setValue(project.includeSubIssues ?? false)
165+
.onChange(async (value) => {
166+
project.includeSubIssues = value;
167+
await this.plugin.saveSettings();
168+
}),
169+
);
170+
}
171+
159172
// ===== PULL REQUESTS STORAGE SECTION =====
160173
new Setting(container).setName("Pull Requests Storage").setHeading();
161174

src/settings/repository-renderer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,18 @@ export class RepositoryRenderer {
294294
await this.plugin.saveSettings();
295295
}),
296296
);
297+
298+
new Setting(issuesSettingsContainer)
299+
.setName("Include sub-issues")
300+
.setDesc("If enabled, sub-issues will be included in the generated files")
301+
.addToggle((toggle) =>
302+
toggle
303+
.setValue(repo.includeSubIssues ?? false)
304+
.onChange(async (value) => {
305+
repo.includeSubIssues = value;
306+
await this.plugin.saveSettings();
307+
}),
308+
);
297309
}
298310

299311
renderPullRequestSettings(

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface RepositoryTracking {
3636
includeClosedIssues: boolean;
3737
includeClosedPullRequests: boolean;
3838
escapeHashTags: boolean;
39+
includeSubIssues: boolean;
3940
}
4041

4142
// Basic project info for selection UI
@@ -81,6 +82,7 @@ export interface TrackedProject {
8182
showEmptyColumns?: boolean;
8283
hiddenStatuses?: string[];
8384
skipHiddenStatusesOnSync?: boolean;
85+
includeSubIssues?: boolean;
8486
}
8587

8688
// GitHub Projects v2 types
@@ -235,4 +237,5 @@ export const DEFAULT_REPOSITORY_TRACKING: RepositoryTracking = {
235237
includeClosedIssues: false,
236238
includeClosedPullRequests: false,
237239
escapeHashTags: false,
240+
includeSubIssues: false,
238241
};

src/util/file-helpers.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { App, TFile } from "obsidian";
22
import { format } from "date-fns";
33
import { escapeBody } from "./escapeUtils";
44
import { NoticeManager } from "../notice-manager";
5+
import { createIssueTemplateData, processFilenameTemplate } from "./templateUtils";
56

67
export class FileHelpers {
78
constructor(
@@ -94,4 +95,57 @@ export class FileHelpers {
9495

9596
return commentSection;
9697
}
98+
99+
/**
100+
* Enrich sub-issues with vault paths if the corresponding files exist
101+
* This allows templates to use internal Obsidian links instead of GitHub URLs
102+
*/
103+
public async enrichSubIssuesWithVaultPaths(
104+
subIssues: any[],
105+
issueFolder: string,
106+
noteTemplate: string,
107+
repository: string,
108+
dateFormat: string,
109+
escapeMode: "disabled" | "normal" | "strict" | "veryStrict"
110+
): Promise<any[]> {
111+
if (!subIssues || subIssues.length === 0) {
112+
return subIssues;
113+
}
114+
115+
return Promise.all(subIssues.map(async (subIssue) => {
116+
const templateData = createIssueTemplateData(
117+
{
118+
title: subIssue.title || "Untitled",
119+
number: subIssue.number,
120+
state: subIssue.state || "open",
121+
user: { login: subIssue.user?.login || "unknown" },
122+
created_at: subIssue.created_at || new Date().toISOString(),
123+
updated_at: subIssue.updated_at || new Date().toISOString(),
124+
html_url: subIssue.html_url || subIssue.url || "",
125+
body: subIssue.body || "",
126+
comments: 0,
127+
locked: false,
128+
},
129+
repository,
130+
[],
131+
dateFormat,
132+
escapeMode,
133+
false
134+
);
135+
136+
const expectedFilename = processFilenameTemplate(noteTemplate, templateData, dateFormat);
137+
const expectedPath = `${issueFolder}/${expectedFilename}.md`;
138+
139+
// Check if the file exists in the vault
140+
const file = this.app.vault.getAbstractFileByPath(expectedPath);
141+
if (file instanceof TFile) {
142+
return {
143+
...subIssue,
144+
vaultPath: expectedFilename,
145+
};
146+
}
147+
148+
return subIssue;
149+
}));
150+
}
97151
}

0 commit comments

Comments
 (0)