Skip to content

Commit ea5154d

Browse files
committed
feat: GitHub Projects integration #30
1 parent b88441a commit ea5154d

14 files changed

Lines changed: 3156 additions & 873 deletions

src/file-manager.ts

Lines changed: 408 additions & 3 deletions
Large diffs are not rendered by default.

src/folder-path-manager.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RepositoryTracking } from "./types";
1+
import { RepositoryTracking, TrackedProject } from "./types";
22

33
export class FolderPathManager {
44
/**
@@ -20,4 +20,36 @@ export class FolderPathManager {
2020
}
2121
return `${repo.pullRequestFolder}/${ownerCleaned}/${repoCleaned}`;
2222
}
23+
24+
public getProjectIssueFolderPath(project: TrackedProject): string {
25+
if (project.useCustomIssueFolder && project.customIssueFolder?.trim()) {
26+
return this.processProjectFolderTemplate(project.customIssueFolder.trim(), project);
27+
}
28+
const folder = project.issueFolder?.trim() || "GitHub/{project}";
29+
return this.processProjectFolderTemplate(folder, project);
30+
}
31+
32+
public getProjectPullRequestFolderPath(project: TrackedProject): string | null {
33+
if (project.useCustomPullRequestFolder && project.customPullRequestFolder?.trim()) {
34+
return this.processProjectFolderTemplate(project.customPullRequestFolder.trim(), project);
35+
}
36+
if (project.pullRequestFolder?.trim()) {
37+
return this.processProjectFolderTemplate(project.pullRequestFolder, project);
38+
}
39+
return null;
40+
}
41+
42+
public processProjectFolderTemplate(folderTemplate: string, project: TrackedProject): string {
43+
return folderTemplate
44+
.replace(/\{project\}/g, this.sanitizeFolderPart(project.title))
45+
.replace(/\{owner\}/g, this.sanitizeFolderPart(project.owner))
46+
.replace(/\{project_number\}/g, project.number.toString());
47+
}
48+
49+
private sanitizeFolderPart(str: string): string {
50+
return str
51+
.replace(/[<>:"|?*\\]/g, "-")
52+
.replace(/\.\./g, ".")
53+
.trim();
54+
}
2355
}

src/github-client.ts

Lines changed: 168 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GitHubTrackerSettings, ProjectData, ProjectFieldValue, ProjectInfo } from "./types";
1+
import { GitHubTrackerSettings, ProjectData, ProjectFieldValue, ProjectInfo, ProjectStatusOption } from "./types";
22
import { Octokit } from "octokit";
33
import { NoticeManager } from "./notice-manager";
44
import {
@@ -8,6 +8,7 @@ import {
88
GET_ORGANIZATION_PROJECTS,
99
GET_USER_PROJECTS,
1010
GET_PROJECT_ITEMS,
11+
GET_PROJECT_FIELDS,
1112
parseItemProjectData,
1213
ProjectItemData,
1314
} from "./github-graphql";
@@ -689,6 +690,130 @@ export class GitHubClient {
689690
});
690691
}
691692

693+
/**
694+
* Fetch all available projects for the authenticated user (user + org projects)
695+
*/
696+
public async fetchAllAvailableProjects(): Promise<ProjectInfo[]> {
697+
if (!this.octokit) {
698+
return [];
699+
}
700+
701+
const projects: ProjectInfo[] = [];
702+
const seenIds = new Set<string>();
703+
704+
try {
705+
// Get authenticated user
706+
const user = await this.fetchAuthenticatedUser();
707+
if (!user) {
708+
this.noticeManager.error("Could not get authenticated user");
709+
return [];
710+
}
711+
712+
// Fetch user's own projects
713+
try {
714+
let hasNextPage = true;
715+
let cursor: string | null = null;
716+
717+
while (hasNextPage) {
718+
const userResponse: any = await this.octokit.graphql(
719+
GET_USER_PROJECTS,
720+
{
721+
user: user,
722+
first: 50,
723+
after: cursor,
724+
},
725+
);
726+
727+
if (userResponse?.user?.projectsV2?.nodes) {
728+
for (const node of userResponse.user.projectsV2.nodes) {
729+
if (!seenIds.has(node.id)) {
730+
seenIds.add(node.id);
731+
projects.push({
732+
id: node.id,
733+
title: node.title,
734+
number: node.number,
735+
url: node.url,
736+
closed: node.closed,
737+
owner: user,
738+
});
739+
}
740+
}
741+
}
742+
743+
hasNextPage = userResponse?.user?.projectsV2?.pageInfo?.hasNextPage ?? false;
744+
cursor = userResponse?.user?.projectsV2?.pageInfo?.endCursor ?? null;
745+
}
746+
} catch (error) {
747+
this.noticeManager.debug(`Error fetching user projects: ${error}`);
748+
}
749+
750+
// Fetch organization projects
751+
try {
752+
let allOrgs: { login: string }[] = [];
753+
let orgsPage = 1;
754+
let hasMoreOrgs = true;
755+
756+
while (hasMoreOrgs) {
757+
const { data: orgs } = await this.octokit.rest.orgs.listForAuthenticatedUser({
758+
per_page: 100,
759+
page: orgsPage,
760+
});
761+
762+
allOrgs = [...allOrgs, ...orgs];
763+
hasMoreOrgs = orgs.length === 100;
764+
orgsPage++;
765+
}
766+
767+
for (const org of allOrgs) {
768+
try {
769+
let hasNextPage = true;
770+
let cursor: string | null = null;
771+
772+
while (hasNextPage) {
773+
const orgResponse: any = await this.octokit.graphql(
774+
GET_ORGANIZATION_PROJECTS,
775+
{
776+
org: org.login,
777+
first: 50,
778+
after: cursor,
779+
},
780+
);
781+
782+
if (orgResponse?.organization?.projectsV2?.nodes) {
783+
for (const node of orgResponse.organization.projectsV2.nodes) {
784+
if (!seenIds.has(node.id)) {
785+
seenIds.add(node.id);
786+
projects.push({
787+
id: node.id,
788+
title: node.title,
789+
number: node.number,
790+
url: node.url,
791+
closed: node.closed,
792+
owner: org.login,
793+
});
794+
}
795+
}
796+
}
797+
798+
hasNextPage = orgResponse?.organization?.projectsV2?.pageInfo?.hasNextPage ?? false;
799+
cursor = orgResponse?.organization?.projectsV2?.pageInfo?.endCursor ?? null;
800+
}
801+
} catch (error) {
802+
this.noticeManager.debug(`Error fetching projects for org ${org.login}: ${error}`);
803+
}
804+
}
805+
} catch (error) {
806+
this.noticeManager.debug(`Error fetching organizations: ${error}`);
807+
}
808+
809+
this.noticeManager.debug(`Found ${projects.length} total projects`);
810+
} catch (error) {
811+
this.noticeManager.error("Error fetching all projects", error);
812+
}
813+
814+
return projects;
815+
}
816+
692817
/**
693818
* Fetch available projects for a repository (includes org projects)
694819
*/
@@ -702,15 +827,12 @@ export class GitHubClient {
702827

703828
const projects: ProjectInfo[] = [];
704829

705-
this.noticeManager.debug(`[Projects] Fetching for owner='${owner}', repo='${repo}'`);
706-
707830
try {
708831
// First, try to get repository-linked projects
709832
let hasNextPage = true;
710833
let cursor: string | null = null;
711834

712835
while (hasNextPage) {
713-
this.noticeManager.debug(`[Projects] Querying repository projects: owner='${owner}', repo='${repo}', after='${cursor}'`);
714836
const response: any = await this.octokit.graphql(
715837
GET_REPOSITORY_PROJECTS,
716838
{
@@ -735,12 +857,10 @@ export class GitHubClient {
735857

736858
hasNextPage = response?.repository?.projectsV2?.pageInfo?.hasNextPage ?? false;
737859
cursor = response?.repository?.projectsV2?.pageInfo?.endCursor ?? null;
738-
this.noticeManager.debug(`[Projects] Repo projects page: found=${response?.repository?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`);
739860
}
740861

741862
// Also try to get organization projects if the owner is an org
742863
try {
743-
this.noticeManager.debug(`[Projects] Querying org projects: org='${owner}', after='${cursor}'`);
744864
hasNextPage = true;
745865
cursor = null;
746866

@@ -771,17 +891,13 @@ export class GitHubClient {
771891

772892
hasNextPage = orgResponse?.organization?.projectsV2?.pageInfo?.hasNextPage ?? false;
773893
cursor = orgResponse?.organization?.projectsV2?.pageInfo?.endCursor ?? null;
774-
this.noticeManager.debug(`[Projects] Org projects page: found=${orgResponse?.organization?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`);
775894
}
776-
} catch (orgError) {
777-
// Owner is probably a user, not an org - that's fine
778-
this.noticeManager.debug(`Could not fetch org projects for ${owner}: likely a user account`);
779-
this.noticeManager.debug(`[Projects] Org projects error: ${orgError}`);
895+
} catch {
896+
// Owner is probably a user, not an org - try user projects instead
780897
}
781898

782899
// Also try to get user projects if the owner is a user
783900
try {
784-
this.noticeManager.debug(`[Projects] Querying user projects: user='${owner}', after='${cursor}'`);
785901
hasNextPage = true;
786902
cursor = null;
787903

@@ -811,17 +927,14 @@ export class GitHubClient {
811927

812928
hasNextPage = userResponse?.user?.projectsV2?.pageInfo?.hasNextPage ?? false;
813929
cursor = userResponse?.user?.projectsV2?.pageInfo?.endCursor ?? null;
814-
this.noticeManager.debug(`[Projects] User projects page: found=${userResponse?.user?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`);
815930
}
816-
} catch (userError) {
817-
this.noticeManager.debug(`Could not fetch user projects for ${owner}: likely an org account`);
818-
this.noticeManager.debug(`[Projects] User projects error: ${userError}`);
931+
} catch {
932+
// Owner is an org, not a user - that's fine
819933
}
820934

821935
this.noticeManager.debug(
822936
`Found ${projects.length} projects for ${owner}/${repo}`,
823937
);
824-
this.noticeManager.debug(`[Projects] Final project count for owner='${owner}', repo='${repo}': ${projects.length}`);
825938
} catch (error) {
826939
this.noticeManager.debug(
827940
`Error fetching projects for ${owner}/${repo}: ${error}`,
@@ -887,6 +1000,44 @@ export class GitHubClient {
8871000
}
8881001
}
8891002

1003+
/**
1004+
* Fetch status field options for a project (in GitHub's order)
1005+
*/
1006+
public async fetchProjectStatusOptions(projectId: string): Promise<ProjectStatusOption[]> {
1007+
if (!this.octokit) {
1008+
return [];
1009+
}
1010+
1011+
try {
1012+
const response: any = await this.octokit.graphql(GET_PROJECT_FIELDS, {
1013+
projectId,
1014+
});
1015+
1016+
if (!response?.node?.fields?.nodes) {
1017+
return [];
1018+
}
1019+
1020+
// Find the Status field (SingleSelectField with name "Status")
1021+
for (const field of response.node.fields.nodes) {
1022+
if (field.name === 'Status' && field.options) {
1023+
return field.options.map((opt: any) => ({
1024+
id: opt.id,
1025+
name: opt.name,
1026+
color: opt.color,
1027+
description: opt.description,
1028+
}));
1029+
}
1030+
}
1031+
1032+
return [];
1033+
} catch (error) {
1034+
this.noticeManager.debug(
1035+
`Error fetching status options for project ${projectId}: ${error}`,
1036+
);
1037+
return [];
1038+
}
1039+
}
1040+
8901041
public dispose(): void {
8911042
this.octokit = null;
8921043
this.currentUser = "";

0 commit comments

Comments
 (0)