From b88441a69e0396f38498fd23338915896137754b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Platte?= Date: Thu, 25 Dec 2025 19:20:08 +0100 Subject: [PATCH 1/4] Started working on project integration #30 - This is very early and not in the state I want yet --- src/content-generator.ts | 58 +++- src/github-client.ts | 325 +++++++++++++++++- src/github-graphql.ts | 677 ++++++++++++++++++++++++++++++++++++++ src/issue-file-manager.ts | 36 +- src/kanban-view.ts | 460 ++++++++++++++++++++++++++ src/main.ts | 29 ++ src/pr-file-manager.ts | 36 +- src/settings-tab.ts | 391 ++++++++++++++++++++++ src/types.ts | 61 ++++ src/util/templateUtils.ts | 104 +++++- styles.css | 152 +++++++++ 11 files changed, 2306 insertions(+), 23 deletions(-) create mode 100644 src/github-graphql.ts create mode 100644 src/kanban-view.ts diff --git a/src/content-generator.ts b/src/content-generator.ts index 958f68d..46a39d7 100644 --- a/src/content-generator.ts +++ b/src/content-generator.ts @@ -1,5 +1,5 @@ import { format } from "date-fns"; -import { GitHubTrackerSettings, RepositoryTracking } from "./types"; +import { GitHubTrackerSettings, RepositoryTracking, ProjectData } from "./types"; import { escapeBody, escapeYamlString } from "./util/escapeUtils"; import { createIssueTemplateData, @@ -21,6 +21,7 @@ export class ContentGenerator { repo: RepositoryTracking, comments: any[], settings: GitHubTrackerSettings, + projectData?: ProjectData[], ): Promise { // Determine whether to escape hash tags (repo setting takes precedence if ignoreGlobalSettings is true) const shouldEscapeHashTags = repo.ignoreGlobalSettings ? repo.escapeHashTags : settings.escapeHashTags; @@ -35,17 +36,19 @@ export class ContentGenerator { comments, settings.dateFormat, settings.escapeMode, - shouldEscapeHashTags + shouldEscapeHashTags, + projectData ); return processContentTemplate(templateContent, templateData, settings.dateFormat); } } // Fallback to default template - return `--- + let frontmatter = `--- title: "${escapeYamlString(issue.title)}" number: ${issue.number} -status: "${issue.state}" +state: "${issue.state}" +type: "issue" created: "${ settings.dateFormat !== "" ? format(new Date(issue.created_at), settings.dateFormat) @@ -69,7 +72,19 @@ labels: [${( ) || [] ).join(", ")}] updateMode: "${repo.issueUpdateMode}" -allowDelete: ${repo.allowDeleteIssue ? true : false} +allowDelete: ${repo.allowDeleteIssue ? true : false}`; + + // Add projectData if available + if (projectData && projectData.length > 0) { + frontmatter += ` +projectData:`; + for (const project of projectData) { + frontmatter += ` + - projectId: "${project.projectId}"`; + } + } + + frontmatter += ` --- # ${escapeBody(issue.title, settings.escapeMode, false)} @@ -79,8 +94,9 @@ ${ : "No description found" } -${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFormat, shouldEscapeHashTags)} -`; +${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFormat, shouldEscapeHashTags)}`; + + return frontmatter; } /** @@ -91,6 +107,7 @@ ${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFo repo: RepositoryTracking, comments: any[], settings: GitHubTrackerSettings, + projectData?: ProjectData[], ): Promise { // Determine whether to escape hash tags (repo setting takes precedence if ignoreGlobalSettings is true) const shouldEscapeHashTags = repo.ignoreGlobalSettings ? repo.escapeHashTags : settings.escapeHashTags; @@ -105,17 +122,19 @@ ${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFo comments, settings.dateFormat, settings.escapeMode, - shouldEscapeHashTags + shouldEscapeHashTags, + projectData ); return processContentTemplate(templateContent, templateData, settings.dateFormat); } } // Fallback to default template - return `--- + let frontmatter = `--- title: "${escapeYamlString(pr.title)}" number: ${pr.number} -status: "${pr.state}" +state: "${pr.state}" +type: "pr" created: "${ settings.dateFormat !== "" ? format(new Date(pr.created_at), settings.dateFormat) @@ -144,7 +163,19 @@ labels: [${( ) || [] ).join(", ")}] updateMode: "${repo.pullRequestUpdateMode}" -allowDelete: ${repo.allowDeletePullRequest ? true : false} +allowDelete: ${repo.allowDeletePullRequest ? true : false}`; + + // Add projectData if available + if (projectData && projectData.length > 0) { + frontmatter += ` +projectData:`; + for (const project of projectData) { + frontmatter += ` + - projectId: "${project.projectId}"`; + } + } + + frontmatter += ` --- # ${escapeBody(pr.title, settings.escapeMode, false)} @@ -154,7 +185,8 @@ ${ : "No description found" } -${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFormat, shouldEscapeHashTags)} -`; +${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFormat, shouldEscapeHashTags)}`; + + return frontmatter; } } diff --git a/src/github-client.ts b/src/github-client.ts index 1f38ff9..06234cf 100644 --- a/src/github-client.ts +++ b/src/github-client.ts @@ -1,6 +1,16 @@ -import { GitHubTrackerSettings } from "./types"; +import { GitHubTrackerSettings, ProjectData, ProjectFieldValue, ProjectInfo } from "./types"; import { Octokit } from "octokit"; import { NoticeManager } from "./notice-manager"; +import { + GET_ITEM_PROJECT_DATA, + GET_ITEMS_PROJECT_DATA_BATCH, + GET_REPOSITORY_PROJECTS, + GET_ORGANIZATION_PROJECTS, + GET_USER_PROJECTS, + GET_PROJECT_ITEMS, + parseItemProjectData, + ProjectItemData, +} from "./github-graphql"; export class GitHubClient { private octokit: Octokit | null = null; @@ -564,6 +574,319 @@ export class GitHubClient { } } + /** + * Fetch project data for a single issue or PR by its node ID + */ + public async fetchProjectDataForItem(nodeId: string): Promise { + if (!this.octokit) { + return []; + } + + try { + const response: any = await this.octokit.graphql(GET_ITEM_PROJECT_DATA, { + nodeId, + }); + + if (!response?.node) { + return []; + } + + const projectItems = parseItemProjectData(response.node); + return this.convertToProjectData(projectItems); + } catch (error) { + this.noticeManager.debug( + `Error fetching project data for item ${nodeId}: ${error}`, + ); + return []; + } + } + + /** + * Batch fetch project data for multiple issues/PRs + * Returns a map of nodeId -> ProjectData[] + */ + public async fetchProjectDataForItems( + nodeIds: string[], + ): Promise> { + const result = new Map(); + + if (!this.octokit || nodeIds.length === 0) { + return result; + } + + // Process in batches of 50 to avoid hitting GraphQL limits + const batchSize = 50; + for (let i = 0; i < nodeIds.length; i += batchSize) { + const batch = nodeIds.slice(i, i + batchSize); + + try { + const response: any = await this.octokit.graphql( + GET_ITEMS_PROJECT_DATA_BATCH, + { nodeIds: batch }, + ); + + if (response?.nodes) { + for (const node of response.nodes) { + if (node?.id) { + const projectItems = parseItemProjectData(node); + result.set(node.id, this.convertToProjectData(projectItems)); + } + } + } + } catch (error) { + this.noticeManager.debug( + `Error fetching batch project data: ${error}`, + ); + // Continue with other batches even if one fails + } + } + + this.noticeManager.debug( + `Fetched project data for ${result.size} items`, + ); + return result; + } + + /** + * Convert parsed project items to ProjectData format + */ + private convertToProjectData(projectItems: ProjectItemData[]): ProjectData[] { + return projectItems.map((item) => { + const customFields: Record = {}; + let status: string | undefined; + let priority: string | undefined; + let iteration: { title: string; startDate: string; duration: number } | undefined; + + for (const field of item.fieldValues) { + // Store in customFields + customFields[field.fieldName] = field; + + // Extract common fields + const fieldNameLower = field.fieldName.toLowerCase(); + if (fieldNameLower === 'status' && field.type === 'single_select') { + status = field.value as string; + } else if (fieldNameLower === 'priority' && field.type === 'single_select') { + priority = field.value as string; + } else if (field.type === 'iteration' && field.startDate && field.duration !== undefined) { + iteration = { + title: field.value as string, + startDate: field.startDate, + duration: field.duration, + }; + } + } + + return { + projectId: item.projectId, + projectTitle: item.projectTitle, + projectNumber: item.projectNumber, + projectUrl: item.projectUrl, + status, + priority, + iteration, + customFields, + }; + }); + } + + /** + * Fetch available projects for a repository (includes org projects) + */ + public async fetchProjectsForRepository( + owner: string, + repo: string, + ): Promise { + if (!this.octokit) { + return []; + } + + const projects: ProjectInfo[] = []; + + this.noticeManager.debug(`[Projects] Fetching for owner='${owner}', repo='${repo}'`); + + try { + // First, try to get repository-linked projects + let hasNextPage = true; + let cursor: string | null = null; + + while (hasNextPage) { + this.noticeManager.debug(`[Projects] Querying repository projects: owner='${owner}', repo='${repo}', after='${cursor}'`); + const response: any = await this.octokit.graphql( + GET_REPOSITORY_PROJECTS, + { + owner, + repo, + first: 50, + after: cursor, + }, + ); + + if (response?.repository?.projectsV2?.nodes) { + for (const node of response.repository.projectsV2.nodes) { + projects.push({ + id: node.id, + title: node.title, + number: node.number, + url: node.url, + closed: node.closed, + }); + } + } + + hasNextPage = response?.repository?.projectsV2?.pageInfo?.hasNextPage ?? false; + cursor = response?.repository?.projectsV2?.pageInfo?.endCursor ?? null; + this.noticeManager.debug(`[Projects] Repo projects page: found=${response?.repository?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`); + } + + // Also try to get organization projects if the owner is an org + try { + this.noticeManager.debug(`[Projects] Querying org projects: org='${owner}', after='${cursor}'`); + hasNextPage = true; + cursor = null; + + while (hasNextPage) { + const orgResponse: any = await this.octokit.graphql( + GET_ORGANIZATION_PROJECTS, + { + org: owner, + first: 50, + after: cursor, + }, + ); + + if (orgResponse?.organization?.projectsV2?.nodes) { + for (const node of orgResponse.organization.projectsV2.nodes) { + // Avoid duplicates + if (!projects.some(p => p.id === node.id)) { + projects.push({ + id: node.id, + title: node.title, + number: node.number, + url: node.url, + closed: node.closed, + }); + } + } + } + + hasNextPage = orgResponse?.organization?.projectsV2?.pageInfo?.hasNextPage ?? false; + cursor = orgResponse?.organization?.projectsV2?.pageInfo?.endCursor ?? null; + this.noticeManager.debug(`[Projects] Org projects page: found=${orgResponse?.organization?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`); + } + } catch (orgError) { + // Owner is probably a user, not an org - that's fine + this.noticeManager.debug(`Could not fetch org projects for ${owner}: likely a user account`); + this.noticeManager.debug(`[Projects] Org projects error: ${orgError}`); + } + + // Also try to get user projects if the owner is a user + try { + this.noticeManager.debug(`[Projects] Querying user projects: user='${owner}', after='${cursor}'`); + hasNextPage = true; + cursor = null; + + while (hasNextPage) { + const userResponse: any = await this.octokit.graphql( + GET_USER_PROJECTS, + { + user: owner, + first: 50, + after: cursor, + }, + ); + + if (userResponse?.user?.projectsV2?.nodes) { + for (const node of userResponse.user.projectsV2.nodes) { + if (!projects.some(p => p.id === node.id)) { + projects.push({ + id: node.id, + title: node.title, + number: node.number, + url: node.url, + closed: node.closed, + }); + } + } + } + + hasNextPage = userResponse?.user?.projectsV2?.pageInfo?.hasNextPage ?? false; + cursor = userResponse?.user?.projectsV2?.pageInfo?.endCursor ?? null; + this.noticeManager.debug(`[Projects] User projects page: found=${userResponse?.user?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`); + } + } catch (userError) { + this.noticeManager.debug(`Could not fetch user projects for ${owner}: likely an org account`); + this.noticeManager.debug(`[Projects] User projects error: ${userError}`); + } + + this.noticeManager.debug( + `Found ${projects.length} projects for ${owner}/${repo}`, + ); + this.noticeManager.debug(`[Projects] Final project count for owner='${owner}', repo='${repo}': ${projects.length}`); + } catch (error) { + this.noticeManager.debug( + `Error fetching projects for ${owner}/${repo}: ${error}`, + ); + } + + return projects; + } + + /** + * Check if token has read:project scope + */ + public async hasProjectScope(): Promise { + const { scopes } = await this.validateToken(); + return scopes.some(scope => + scope === 'read:project' || + scope === 'project' || + scope === 'repo' // repo scope includes project access + ); + } + + /** + * Fetch all items for a specific project + */ + public async fetchProjectItems(projectId: string): Promise { + if (!this.octokit) { + return []; + } + + try { + let allItems: any[] = []; + let hasNextPage = true; + let cursor: string | null = null; + + while (hasNextPage) { + const response: any = await this.octokit.graphql( + GET_PROJECT_ITEMS, + { + projectId, + first: 50, + after: cursor, + }, + ); + + if (response?.node?.items?.nodes) { + allItems = [...allItems, ...response.node.items.nodes]; + } + + hasNextPage = response?.node?.items?.pageInfo?.hasNextPage ?? false; + cursor = response?.node?.items?.pageInfo?.endCursor ?? null; + } + + this.noticeManager.debug( + `Fetched ${allItems.length} items for project ${projectId}`, + ); + return allItems; + } catch (error) { + this.noticeManager.error( + `Error fetching project items for ${projectId}`, + error, + ); + return []; + } + } + public dispose(): void { this.octokit = null; this.currentUser = ""; diff --git a/src/github-graphql.ts b/src/github-graphql.ts new file mode 100644 index 0000000..f9165e9 --- /dev/null +++ b/src/github-graphql.ts @@ -0,0 +1,677 @@ +/** + * GraphQL queries for GitHub Projects v2 API + */ + +// Query to get projects linked to a repository +export const GET_REPOSITORY_PROJECTS = ` +query GetRepositoryProjects($owner: String!, $repo: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + projectsV2(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + title + number + url + closed + } + } + } +} +`; + +// Query to get projects for an organization +export const GET_ORGANIZATION_PROJECTS = ` +query GetOrganizationProjects($org: String!, $first: Int!, $after: String) { + organization(login: $org) { + projectsV2(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + title + number + url + closed + } + } + } +} +`; + +// Query to get projects for a user +export const GET_USER_PROJECTS = ` +query GetUserProjects($user: String!, $first: Int!, $after: String) { + user(login: $user) { + projectsV2(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + title + number + url + closed + } + } + } +} +`; + +// Query to get project fields (to understand the structure) +export const GET_PROJECT_FIELDS = ` +query GetProjectFields($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + fields(first: 50) { + nodes { + ... on ProjectV2Field { + id + name + dataType + } + ... on ProjectV2IterationField { + id + name + dataType + configuration { + iterations { + id + title + startDate + duration + } + } + } + ... on ProjectV2SingleSelectField { + id + name + dataType + options { + id + name + color + description + } + } + } + } + } + } +} +`; + +// Query to get project data for a specific issue or PR by node ID +export const GET_ITEM_PROJECT_DATA = ` +query GetItemProjectData($nodeId: ID!) { + node(id: $nodeId) { + ... on Issue { + projectItems(first: 10) { + nodes { + id + project { + id + title + number + url + } + fieldValues(first: 30) { + nodes { + ... on ProjectV2ItemFieldTextValue { + text + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldNumberValue { + number + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldDateValue { + date + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + ... on ProjectV2ItemFieldIterationValue { + title + startDate + duration + iterationId + field { + ... on ProjectV2IterationField { + name + } + } + } + ... on ProjectV2ItemFieldUserValue { + users(first: 10) { + nodes { + login + } + } + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldLabelValue { + labels(first: 20) { + nodes { + name + } + } + field { + ... on ProjectV2Field { + name + } + } + } + } + } + } + } + } + ... on PullRequest { + projectItems(first: 10) { + nodes { + id + project { + id + title + number + url + } + fieldValues(first: 30) { + nodes { + ... on ProjectV2ItemFieldTextValue { + text + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldNumberValue { + number + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldDateValue { + date + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + ... on ProjectV2ItemFieldIterationValue { + title + startDate + duration + iterationId + field { + ... on ProjectV2IterationField { + name + } + } + } + ... on ProjectV2ItemFieldUserValue { + users(first: 10) { + nodes { + login + } + } + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldLabelValue { + labels(first: 20) { + nodes { + name + } + } + field { + ... on ProjectV2Field { + name + } + } + } + } + } + } + } + } + } +} +`; + +// Query to batch-fetch project data for multiple issues/PRs +export const GET_ITEMS_PROJECT_DATA_BATCH = ` +query GetItemsProjectDataBatch($nodeIds: [ID!]!) { + nodes(ids: $nodeIds) { + ... on Issue { + id + number + projectItems(first: 10) { + nodes { + id + project { + id + title + number + url + } + fieldValues(first: 30) { + nodes { + ... on ProjectV2ItemFieldTextValue { + text + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldNumberValue { + number + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldDateValue { + date + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + ... on ProjectV2ItemFieldIterationValue { + title + startDate + duration + iterationId + field { + ... on ProjectV2IterationField { + name + } + } + } + ... on ProjectV2ItemFieldUserValue { + users(first: 10) { + nodes { + login + } + } + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldLabelValue { + labels(first: 20) { + nodes { + name + } + } + field { + ... on ProjectV2Field { + name + } + } + } + } + } + } + } + } + ... on PullRequest { + id + number + projectItems(first: 10) { + nodes { + id + project { + id + title + number + url + } + fieldValues(first: 30) { + nodes { + ... on ProjectV2ItemFieldTextValue { + text + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldNumberValue { + number + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldDateValue { + date + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + ... on ProjectV2ItemFieldIterationValue { + title + startDate + duration + iterationId + field { + ... on ProjectV2IterationField { + name + } + } + } + ... on ProjectV2ItemFieldUserValue { + users(first: 10) { + nodes { + login + } + } + field { + ... on ProjectV2Field { + name + } + } + } + ... on ProjectV2ItemFieldLabelValue { + labels(first: 20) { + nodes { + name + } + } + field { + ... on ProjectV2Field { + name + } + } + } + } + } + } + } + } + } +} +`; + +// Types for GraphQL responses +export interface ProjectV2Node { + id: string; + title: string; + number: number; + url: string; + closed: boolean; +} + +export interface ProjectFieldValue { + fieldName: string; + type: 'text' | 'number' | 'date' | 'single_select' | 'iteration' | 'user' | 'labels'; + value: string | number | null; + // Additional data for specific types + startDate?: string; + duration?: number; + users?: string[]; + labels?: string[]; +} + +export interface ProjectItemData { + projectId: string; + projectTitle: string; + projectNumber: number; + projectUrl: string; + fieldValues: ProjectFieldValue[]; +} + +export interface ItemProjectData { + nodeId: string; + itemNumber: number; + projects: ProjectItemData[]; +} + +/** + * Parse field values from GraphQL response into a normalized format + */ +export function parseFieldValues(fieldValuesNodes: any[]): ProjectFieldValue[] { + const fieldValues: ProjectFieldValue[] = []; + + for (const node of fieldValuesNodes) { + if (!node || !node.field?.name) continue; + + const fieldName = node.field.name; + + // Text field + if ('text' in node && node.text !== undefined) { + fieldValues.push({ + fieldName, + type: 'text', + value: node.text, + }); + } + // Number field + else if ('number' in node && node.number !== undefined) { + fieldValues.push({ + fieldName, + type: 'number', + value: node.number, + }); + } + // Date field + else if ('date' in node && node.date !== undefined) { + fieldValues.push({ + fieldName, + type: 'date', + value: node.date, + }); + } + // Single select field + else if ('name' in node && 'optionId' in node) { + fieldValues.push({ + fieldName, + type: 'single_select', + value: node.name, + }); + } + // Iteration field + else if ('title' in node && 'iterationId' in node) { + fieldValues.push({ + fieldName, + type: 'iteration', + value: node.title, + startDate: node.startDate, + duration: node.duration, + }); + } + // User field + else if ('users' in node && node.users?.nodes) { + const userLogins = node.users.nodes.map((u: any) => u.login); + fieldValues.push({ + fieldName, + type: 'user', + value: userLogins.join(', '), + users: userLogins, + }); + } + // Labels field + else if ('labels' in node && node.labels?.nodes) { + const labelNames = node.labels.nodes.map((l: any) => l.name); + fieldValues.push({ + fieldName, + type: 'labels', + value: labelNames.join(', '), + labels: labelNames, + }); + } + } + + return fieldValues; +} + +/** + * Parse a single item's project data from GraphQL response + */ +export function parseItemProjectData(itemNode: any): ProjectItemData[] { + if (!itemNode?.projectItems?.nodes) { + return []; + } + + const projects: ProjectItemData[] = []; + + for (const projectItem of itemNode.projectItems.nodes) { + if (!projectItem?.project) continue; + + projects.push({ + projectId: projectItem.project.id, + projectTitle: projectItem.project.title, + projectNumber: projectItem.project.number, + projectUrl: projectItem.project.url, + fieldValues: parseFieldValues(projectItem.fieldValues?.nodes || []), + }); + } + + return projects; +} + +// Query to get all items for a specific project +export const GET_PROJECT_ITEMS = ` +query GetProjectItems($projectId: ID!, $first: Int!, $after: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + content { + ... on Issue { + number + title + url + } + ... on PullRequest { + number + title + url + } + } + fieldValues(first: 20) { + nodes { + ... on ProjectV2ItemFieldTextValue { + field { + ... on ProjectV2Field { + name + } + } + text + } + ... on ProjectV2ItemFieldSingleSelectValue { + field { + ... on ProjectV2SingleSelectField { + name + } + } + name + } + ... on ProjectV2ItemFieldDateValue { + field { + ... on ProjectV2Field { + name + } + } + date + } + ... on ProjectV2ItemFieldUserValue { + field { + ... on ProjectV2Field { + name + } + } + users(first: 10) { + nodes { + login + } + } + } + } + } + } + } + } + } +} +`; diff --git a/src/issue-file-manager.ts b/src/issue-file-manager.ts index 25f5019..ff7f747 100644 --- a/src/issue-file-manager.ts +++ b/src/issue-file-manager.ts @@ -1,5 +1,5 @@ import { App, TFile } from "obsidian"; -import { GitHubTrackerSettings, RepositoryTracking } from "./types"; +import { GitHubTrackerSettings, RepositoryTracking, ProjectData } from "./types"; import { escapeBody } from "./util/escapeUtils"; import { NoticeManager } from "./notice-manager"; import { GitHubClient } from "./github-client"; @@ -56,13 +56,43 @@ export class IssueFileManager { allIssuesIncludingRecentlyClosed, ); + // Batch fetch project data if tracking is enabled globally + let projectDataMap = new Map(); + if (this.settings.enableProjectTracking) { + const nodeIds = openIssues + .filter((issue: any) => issue.node_id) + .map((issue: any) => issue.node_id); + + if (nodeIds.length > 0) { + this.noticeManager.debug( + `Fetching project data for ${nodeIds.length} issues` + ); + projectDataMap = await this.gitHubClient.fetchProjectDataForItems(nodeIds); + } + } + + // Get enabled project IDs from global settings + const enabledProjectIds = this.settings.trackedProjects + .filter(p => p.enabled) + .map(p => p.id); + // Create or update issue files (openIssues contains filtered issues from main.ts) for (const issue of openIssues) { + let projectData = issue.node_id ? projectDataMap.get(issue.node_id) : undefined; + + // Filter by enabled projects from global settings + if (projectData && enabledProjectIds.length > 0) { + projectData = projectData.filter(p => + enabledProjectIds.includes(p.projectId) + ); + } + await this.createOrUpdateIssueFile( effectiveRepo, ownerCleaned, repoCleaned, issue, + projectData, ); } } @@ -72,6 +102,7 @@ export class IssueFileManager { ownerCleaned: string, repoCleaned: string, issue: any, + projectData?: ProjectData[], ): Promise { // Generate filename using template const templateData = createIssueTemplateData(issue, repo.repository); @@ -112,7 +143,7 @@ export class IssueFileManager { ); } - let content = await this.contentGenerator.createIssueContent(issue, repo, comments, this.settings); + let content = await this.contentGenerator.createIssueContent(issue, repo, comments, this.settings, projectData); if (file) { if (file instanceof TFile) { @@ -145,6 +176,7 @@ export class IssueFileManager { repo, comments, this.settings, + projectData, ); // Merge persist blocks back into new content diff --git a/src/kanban-view.ts b/src/kanban-view.ts new file mode 100644 index 0000000..2e1f9a9 --- /dev/null +++ b/src/kanban-view.ts @@ -0,0 +1,460 @@ +import { ItemView, WorkspaceLeaf, TFile, Notice } from "obsidian"; +import { GitHubTrackerSettings, ProjectData } from "./types"; + +export const KANBAN_VIEW_TYPE = "github-kanban-view"; + +export class GitHubKanbanView extends ItemView { + private settings: GitHubTrackerSettings; + private refreshInterval: NodeJS.Timeout | null = null; + private projectDataCache: Map = new Map(); + + private normalizeUrl(url?: string): string | null { + if (!url) return null; + try { + const u = new URL(url); + let p = u.origin + u.pathname; + // Remove trailing slash + if (p.endsWith('/')) p = p.slice(0, -1); + return p.toLowerCase(); + } catch { + // Fallback: simple trim and toLower + return url.replace(/\/$/, '').toLowerCase(); + } + } + + /** + * Parse a frontmatter number value into a numeric ID (robust to strings and quoted values) + */ + private parseNumber(val: any): number | null { + if (val === undefined || val === null) return null; + if (typeof val === 'number') return val; + const s = String(val).trim().replace(/^"|"$/g, '').replace(/[^0-9-]/g, ''); + if (s === '') return null; + const n = Number(s); + return isNaN(n) ? null : n; + } + private gitHubClient: any = null; + + constructor(leaf: WorkspaceLeaf, settings: GitHubTrackerSettings, gitHubClient: any) { + super(leaf); + this.settings = settings; + this.gitHubClient = gitHubClient; + } + + getViewType(): string { + return KANBAN_VIEW_TYPE; + } + + getDisplayText(): string { + return "GitHub Projects Kanban"; + } + + getIcon(): string { + return "kanban"; + } + + async onOpen(): Promise { + await this.loadProjectDataCache(); + await this.render(); + this.startAutoRefresh(); + } + + async onClose(): Promise { + this.stopAutoRefresh(); + } + + private startAutoRefresh(): void { + // Refresh every 5 minutes + this.refreshInterval = setInterval(() => { + this.render(); + }, 5 * 60 * 1000); + } + + private stopAutoRefresh(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + async render(): Promise { + const container = this.containerEl.children[1]; + container.empty(); + + // Reload project data cache + await this.loadProjectDataCache(); + + // Header + const header = container.createDiv("github-kanban-header"); + header.createEl("h2", { text: "GitHub Projects Kanban" }); + + const refreshButton = header.createEl("button", { + text: "🔄 Refresh", + cls: "github-kanban-refresh-btn" + }); + refreshButton.onclick = () => this.render(); + + // Use all cached projects (including those not explicitly tracked) + const cachedProjectIds = Array.from(this.projectDataCache.keys()); + if (cachedProjectIds.length === 0) { + container.createEl("p", { + text: "No project items found. Try refreshing or enable projects in settings.", + cls: "github-kanban-empty" + }); + return; + } + + // Build project info objects from cache (use first item to get projectTitle/number/url) + const projectsToRender: any[] = []; + for (const projectId of cachedProjectIds) { + const projectItems = this.projectDataCache.get(projectId) || []; + if (projectItems.length === 0) continue; + const sample = projectItems[0]; + projectsToRender.push({ id: projectId, title: sample.projectTitle || projectId, number: sample.projectNumber || 0, url: sample.projectUrl || '' }); + } + + // Create Kanban board for each discovered project + for (const project of projectsToRender) { + await this.renderProjectBoard(container, project); + } + } + + private async renderProjectBoard(container: Element, project: any): Promise { + const projectContainer = container.createDiv("github-kanban-project"); + projectContainer.createEl("h3", { text: `${project.title} (#${project.number})` }); + + const boardContainer = projectContainer.createDiv("github-kanban-board"); + + // Get issues/PRs for this project + const items = await this.getProjectItems(project); + + // Group by status + const statusColumns = this.groupItemsByStatus(items); + + // Create columns dynamically based on found statuses + const allStatuses = Array.from(statusColumns.keys()); + // Sort statuses, put "No Status" at the end + const sortedStatuses = allStatuses + .filter(status => status !== "No Status") + .sort() + .concat(allStatuses.includes("No Status") ? ["No Status"] : []); + + for (const status of sortedStatuses) { + const columnItems = statusColumns.get(status) || []; + this.renderColumn(boardContainer, status, columnItems); + } + } + + private async loadProjectDataCache(): Promise { + this.projectDataCache.clear(); + + // Build a deduplicated list of projects to fetch: include all tracked projects and all projects found in configured repositories + const projectsToFetch = new Map(); + + // Include tracked projects (if any) + for (const p of (this.settings.trackedProjects || [])) { + projectsToFetch.set(p.id, { id: p.id, title: p.title, number: p.number, url: p.url }); + } + + // Also fetch projects directly from each configured repository (so we show ALL projects) + for (const repoCfg of (this.settings.repositories || [])) { + const [owner, repoName] = (repoCfg.repository || '').split('/'); + if (!owner || !repoName) continue; + try { + const repoProjects = await this.gitHubClient.fetchProjectsForRepository(owner, repoName); + for (const rp of repoProjects) { + if (!projectsToFetch.has(rp.id)) { + projectsToFetch.set(rp.id, { id: rp.id, title: rp.title, number: rp.number, url: rp.url }); + } + } + } catch (err) { + console.error(`Error fetching projects for ${repoCfg.repository}:`, err); + } + } + + // Now fetch items for each discovered project + for (const [projectId, projInfo] of projectsToFetch.entries()) { + try { + const projectItems = await this.gitHubClient.fetchProjectItems(projectId); + const itemsArray: any[] = []; + for (const item of projectItems) { + if (!item.content) continue; + const contentUrl: string | undefined = item.content.url; + const normalizedUrl = this.normalizeUrl(contentUrl) as string | null; + + // Parse custom fields + const customFields: any = {}; + for (const fieldValue of item.fieldValues?.nodes || []) { + if (!fieldValue.field?.name) continue; + const fieldName = fieldValue.field.name; + if (fieldValue.text !== undefined) { + customFields[fieldName] = { fieldName, type: 'text', value: fieldValue.text }; + } else if (fieldValue.name !== undefined) { + customFields[fieldName] = { fieldName, type: 'single_select', value: fieldValue.name }; + } else if (fieldValue.date !== undefined) { + customFields[fieldName] = { fieldName, type: 'date', value: fieldValue.date }; + } else if (fieldValue.users?.nodes) { + customFields[fieldName] = { fieldName, type: 'user', value: fieldValue.users.nodes.map((u: any) => u.login).join(', '), users: fieldValue.users.nodes.map((u: any) => u.login) }; + } + } + + const projectData = { + projectId: projectId, + projectTitle: projInfo.title, + projectNumber: projInfo.number, + projectUrl: projInfo.url, + itemId: item.id, + number: item.content.number, + title: item.content.title, + url: contentUrl, + normalizedUrl, + customFields, + status: customFields?.Status?.value ?? null + }; + itemsArray.push(projectData); + } + + this.projectDataCache.set(projectId, itemsArray); + console.log(`Project ${projectId} cached ${itemsArray.length} items, sample:`, itemsArray.slice(0, 6).map(i => ({ number: i.number, url: i.url, normalizedUrl: i.normalizedUrl, status: i.status }))); + } catch (error) { + console.error(`Error loading project data for ${projectId}:`, error); + } + } + + console.log(`Loaded project data cache for ${Array.from(this.projectDataCache.keys()).length} projects`); + } + + private async getProjectItems(project: any): Promise { + const items: any[] = []; + + // Get all markdown files + const files = this.app.vault.getMarkdownFiles(); + const matchedNumbers = new Set(); + const matchedUrls = new Set(); + + for (const file of files) { + try { + // Read file content + const content = await this.app.vault.read(file); + + // Parse frontmatter to get project data + const frontmatter = this.parseFrontmatter(content); + if (!frontmatter) continue; + + // Check if this is an issue or PR by looking at frontmatter + const isIssue = frontmatter.number && frontmatter.title && frontmatter.state; + if (!isIssue) continue; + + // Check project cache entries for this project + const cachedItemsForProject = this.projectDataCache.get(project.id) || []; + const itemUrl = frontmatter.url; + const normalizedItemUrl = this.normalizeUrl(itemUrl); + + // Debug log + if (itemUrl) console.log(`Checking item ${frontmatter.title} (${itemUrl}) in project ${project.id} (normalized: ${normalizedItemUrl})`); + +// Try to find a match by number first (robust), then by normalized URL + let fullProjectData: any = null; + const fmNum = this.parseNumber(frontmatter.number); + console.log(`Parsed frontmatter number for '${frontmatter.title}':`, frontmatter.number, '->', fmNum); + if (fmNum !== null) { + fullProjectData = cachedItemsForProject.find((ci: any) => Number(ci.number) === fmNum) || null; + if (fullProjectData) matchedNumbers.add(fmNum); + } + if (!fullProjectData && normalizedItemUrl) { + fullProjectData = cachedItemsForProject.find((ci: any) => ci.normalizedUrl === normalizedItemUrl) || null; + if (fullProjectData && fullProjectData.normalizedUrl) matchedUrls.add(fullProjectData.normalizedUrl); + } + if (fullProjectData) { + // Debug: print the matched project data for this item + console.log(`Matched project data for ${frontmatter.title}:`, fullProjectData); + // Extract status from top-level status or customFields.Status.value + let projectStatus = fullProjectData?.status || fullProjectData?.customFields?.Status?.value || "No Status"; + + // Extract item info with project details + const item = { + ...frontmatter, + file: file, + title: frontmatter.title, + number: frontmatter.number, + state: frontmatter.state, + labels: frontmatter.labels || [], + pull_request: frontmatter.type === "pr", + projectStatus: projectStatus, + projectTitle: project.title, + projectNumber: project.number, + projectUrl: project.url, + fullProjectData: fullProjectData + }; + items.push(item); + } + } catch (error) { + console.error(`Error processing file ${file.path}:`, error); + } + } + + + // Add remote-only project items (those that are in the project but have no local file) + const cachedItemsForProject = this.projectDataCache.get(project.id) || []; + for (const ci of cachedItemsForProject) { + const ciNum = Number(ci.number); + const ciNorm = ci.normalizedUrl; + if ((ciNum && matchedNumbers.has(ciNum)) || (ciNorm && matchedUrls.has(ciNorm))) continue; + + // Synthesize an item for display + const synthetic: any = { + title: ci.title || `#${ci.number}`, + number: ci.number, + state: ci.status || 'unknown', + labels: [], + pull_request: false, + projectStatus: ci.status || 'No Status', + projectTitle: project.title, + projectNumber: project.number, + projectUrl: project.url, + fullProjectData: ci, + remoteOnly: true, + url: ci.url + }; + items.push(synthetic); + } + + return items; + } + + private parseFrontmatter(content: string): any | null { + const frontmatterRegex = /^---\n([\s\S]*?)\n---/; + const match = content.match(frontmatterRegex); + if (!match) return null; + + try { + const frontmatter = match[1]; + const lines = frontmatter.split('\n'); + const result: any = {}; + + for (const line of lines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim(); + let value = line.substring(colonIndex + 1).trim(); + + // Try to parse as JSON if it looks like an array/object + if (value.startsWith('[') || value.startsWith('{')) { + try { + result[key] = JSON.parse(value); + } catch { + result[key] = value; + } + } else { + result[key] = value; + } + } + } + + return result; + } catch (error) { + console.error('Error parsing frontmatter:', error); + return null; + } + } + + private groupItemsByStatus(items: any[]): Map { + const groups = new Map(); + + for (const item of items) { + const status = item.projectStatus || "No Status"; + if (!groups.has(status)) { + groups.set(status, []); + } + groups.get(status)!.push(item); + } + + return groups; + } + + private renderColumn(container: Element, status: string, items: any[]): void { + const column = container.createDiv("github-kanban-column"); + column.createEl("h4", { text: `${status} (${items.length})` }); + + const itemsContainer = column.createDiv("github-kanban-items"); + + for (const item of items) { + this.renderKanbanItem(itemsContainer, item); + } + } + + private renderKanbanItem(container: Element, item: any): void { + const itemEl = container.createDiv("github-kanban-item"); + + // Title + const titleEl = itemEl.createEl("div", "github-kanban-item-title"); + titleEl.setText(item.title || "Untitled"); + + // Number and type + const metaEl = itemEl.createEl("div", "github-kanban-item-meta"); + const type = item.pull_request ? "PR" : "Issue"; + const number = item.number; + metaEl.setText(`#${number} (${type})`); + + // Labels + if (item.labels && item.labels.length > 0) { + const labelsEl = itemEl.createEl("div", "github-kanban-item-labels"); + for (const label of item.labels.slice(0, 3)) { // Show max 3 labels + const labelEl = labelsEl.createEl("span", "github-kanban-label"); + labelEl.setText(label.name); + labelEl.style.backgroundColor = `#${label.color}`; + } + } + + // Show project status and custom fields + if (item.projectStatus) { + const statusBadge = itemEl.createEl('div', { cls: 'github-kanban-item-meta' }); + statusBadge.setText(`Project status: ${item.projectStatus}`); + } + + if (item.fullProjectData?.customFields) { + const cfEl = itemEl.createDiv('github-kanban-item-labels'); + for (const [key, val] of Object.entries(item.fullProjectData.customFields)) { + const entry = cfEl.createEl('div', { cls: 'github-kanban-item-meta' }); + let vdisp = ''; + if (val && typeof val === 'object') { + const fieldObj = val as any; + vdisp = fieldObj.value ?? ''; + } + entry.setText(`${key}: ${vdisp}`); + } + } + + // Show raw project item data (collapsible) + if (item.fullProjectData) { + const details = itemEl.createEl('details', { cls: 'github-kanban-item-meta' }); + details.createEl('summary', { text: 'Show raw project data' }); + const pre = details.createEl('pre'); + pre.setText(JSON.stringify(item.fullProjectData, null, 2)); + } + + // Make clickable to open the file + itemEl.onclick = () => this.openItemFile(item); + } + + private async openItemFile(item: any): Promise { + // Try to find the file by matching frontmatter number and repo + const files = this.app.vault.getMarkdownFiles(); + for (const file of files) { + try { + const content = await this.app.vault.read(file); + const fm = this.parseFrontmatter(content); + if (!fm) continue; + // Match by number and (optionally) repo derived from URL + if (fm.number && item.number && fm.number.toString() === item.number.toString()) { + await this.app.workspace.getLeaf().openFile(file); + return; + } + } catch (e) { + // ignore + } + } + + new Notice(`File for #${item.number} not found in vault`); + } +} diff --git a/src/main.ts b/src/main.ts index b69f612..611fc24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { GitHubClient } from "./github-client"; import { FileManager } from "./file-manager"; import { GitHubTrackerSettingTab } from "./settings-tab"; import { NoticeManager } from "./notice-manager"; +import { GitHubKanbanView, KANBAN_VIEW_TYPE } from "./kanban-view"; export default class GitHubTrackerPlugin extends Plugin { settings: GitHubTrackerSettings = DEFAULT_SETTINGS; @@ -222,10 +223,38 @@ export default class GitHubTrackerPlugin extends Plugin { name: "Sync GitHub issues & pull requests", callback: () => this.sync(), }); + + // Register Kanban View + this.registerView( + KANBAN_VIEW_TYPE, + (leaf) => new GitHubKanbanView(leaf, this.settings, this.gitHubClient) + ); + + this.addCommand({ + id: "open-kanban-view", + name: "Open GitHub Projects Kanban", + callback: () => this.openKanbanView(), + }); + this.addSettingTab(new GitHubTrackerSettingTab(this.app, this)); this.startBackgroundSync(); } + private async openKanbanView(): Promise { + const existing = this.app.workspace.getLeavesOfType(KANBAN_VIEW_TYPE); + if (existing.length > 0) { + this.app.workspace.revealLeaf(existing[0]); + return; + } + + const leaf = this.app.workspace.getLeaf(); + await leaf.setViewState({ + type: KANBAN_VIEW_TYPE, + active: true, + }); + this.app.workspace.revealLeaf(leaf); + } + onunload() { this.stopBackgroundSync(); this.gitHubClient?.dispose(); diff --git a/src/pr-file-manager.ts b/src/pr-file-manager.ts index 7478b54..ae2fe89 100644 --- a/src/pr-file-manager.ts +++ b/src/pr-file-manager.ts @@ -1,5 +1,5 @@ import { App, TFile } from "obsidian"; -import { GitHubTrackerSettings, RepositoryTracking } from "./types"; +import { GitHubTrackerSettings, RepositoryTracking, ProjectData } from "./types"; import { escapeBody } from "./util/escapeUtils"; import { NoticeManager } from "./notice-manager"; import { GitHubClient } from "./github-client"; @@ -58,13 +58,43 @@ export class PullRequestFileManager { allPullRequestsIncludingRecentlyClosed, ); + // Batch fetch project data if tracking is enabled globally + let projectDataMap = new Map(); + if (this.settings.enableProjectTracking) { + const nodeIds = openPullRequests + .filter((pr: any) => pr.node_id) + .map((pr: any) => pr.node_id); + + if (nodeIds.length > 0) { + this.noticeManager.debug( + `Fetching project data for ${nodeIds.length} pull requests` + ); + projectDataMap = await this.gitHubClient.fetchProjectDataForItems(nodeIds); + } + } + + // Get enabled project IDs from global settings + const enabledProjectIds = this.settings.trackedProjects + .filter(p => p.enabled) + .map(p => p.id); + // Create or update pull request files (openPullRequests contains filtered PRs from main.ts) for (const pr of openPullRequests) { + let projectData = pr.node_id ? projectDataMap.get(pr.node_id) : undefined; + + // Filter by enabled projects from global settings + if (projectData && enabledProjectIds.length > 0) { + projectData = projectData.filter(p => + enabledProjectIds.includes(p.projectId) + ); + } + await this.createOrUpdatePullRequestFile( effectiveRepo, ownerCleaned, repoCleaned, pr, + projectData, ); } } @@ -74,6 +104,7 @@ export class PullRequestFileManager { ownerCleaned: string, repoCleaned: string, pr: any, + projectData?: ProjectData[], ): Promise { // Generate filename using template const templateData = createPullRequestTemplateData(pr, repo.repository); @@ -114,7 +145,7 @@ export class PullRequestFileManager { ); } - let content = await this.contentGenerator.createPullRequestContent(pr, repo, comments, this.settings); + let content = await this.contentGenerator.createPullRequestContent(pr, repo, comments, this.settings, projectData); if (file) { if (file instanceof TFile) { @@ -147,6 +178,7 @@ export class PullRequestFileManager { repo, comments, this.settings, + projectData, ); // Merge persist blocks back into new content diff --git a/src/settings-tab.ts b/src/settings-tab.ts index f011ae2..2df290c 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -570,6 +570,146 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { }) ); + // GitHub Projects Section + const projectsContainer = containerEl.createDiv("github-issues-settings-group"); + projectsContainer.style.marginTop = "30px"; + + new Setting(projectsContainer).setName("GitHub Projects").setHeading(); + + projectsContainer + .createEl("p", { + text: "Track GitHub Projects (v2) data and make project fields available as template variables for issues and pull requests.", + }) + .addClass("setting-item-description"); + + const projectSettingsContainer = projectsContainer.createDiv( + "github-issues-settings-group", + ); + + new Setting(projectsContainer) + .setName("Enable project tracking") + .setDesc( + "When enabled, project data like status, priority, and custom fields become available as template variables.", + ) + .addToggle((toggle) => + toggle.setValue(this.plugin.settings.enableProjectTracking).onChange(async (value) => { + this.plugin.settings.enableProjectTracking = value; + projectSettingsContainer.classList.toggle( + "github-issues-settings-hidden", + !value, + ); + await this.plugin.saveSettings(); + }), + ); + + projectSettingsContainer.classList.toggle( + "github-issues-settings-hidden", + !this.plugin.settings.enableProjectTracking, + ); + + // Project list + const projectListContainer = projectSettingsContainer.createDiv( + "github-issues-project-list", + ); + + this.renderTrackedProjects(projectListContainer); + + // Load projects button + const loadProjectsContainer = projectSettingsContainer.createDiv(); + loadProjectsContainer.style.display = "flex"; + loadProjectsContainer.style.flexDirection = "column"; + loadProjectsContainer.style.gap = "8px"; + + const loadProjectsButton = loadProjectsContainer.createEl("button"); + loadProjectsButton.setText("Load Projects"); + loadProjectsButton.title = "Load projects from tracked repositories"; + + const loadDirectContainer = loadProjectsContainer.createDiv(); + loadDirectContainer.style.display = "flex"; + loadDirectContainer.style.alignItems = "center"; + loadDirectContainer.style.gap = "8px"; + + const directRepoInput = loadDirectContainer.createEl("input"); + directRepoInput.type = "text"; + directRepoInput.placeholder = "owner/repo-name"; + directRepoInput.style.flex = "1"; + directRepoInput.style.padding = "4px 8px"; + directRepoInput.style.border = "1px solid var(--background-modifier-border)"; + directRepoInput.style.borderRadius = "4px"; + + const loadDirectButton = loadDirectContainer.createEl("button"); + loadDirectButton.setText("Load from Repo"); + loadDirectButton.title = "Load projects directly from a specific repository"; + + loadProjectsButton.onclick = async () => { + loadProjectsButton.disabled = true; + loadProjectsButton.setText("Loading..."); + + try { + await this.loadProjectsFromRepositories(); + projectListContainer.empty(); + this.renderTrackedProjects(projectListContainer); + } catch (error) { + new Notice(`Error loading projects: ${error}`); + } finally { + loadProjectsButton.disabled = false; + loadProjectsButton.setText("Load Projects"); + } + }; + + loadDirectButton.onclick = async () => { + const repoInput = directRepoInput.value.trim(); + if (!repoInput) { + new Notice("Please enter a repository in owner/repo-name format"); + return; + } + + const [owner, repoName] = repoInput.split("/"); + if (!owner || !repoName) { + new Notice("Please enter repository in owner/repo-name format"); + return; + } + + loadDirectButton.disabled = true; + loadDirectButton.setText("Loading..."); + + try { + await this.loadProjectsFromDirectRepository(owner, repoName); + projectListContainer.empty(); + this.renderTrackedProjects(projectListContainer); + } catch (error) { + new Notice(`Error loading projects: ${error}`); + } finally { + loadDirectButton.disabled = false; + loadDirectButton.setText("Load from Repo"); + } + }; + + // Template variables info + const projectsInfo = projectSettingsContainer.createDiv(); + projectsInfo.addClass("github-issues-info-text"); + projectsInfo.style.marginTop = "8px"; + + const projectsDetails = projectsInfo.createEl("details"); + const projectsSummary = projectsDetails.createEl("summary"); + projectsSummary.textContent = "Available project template variables"; + projectsSummary.style.cursor = "pointer"; + projectsSummary.style.fontWeight = "500"; + + const projectsContent = projectsDetails.createDiv(); + projectsContent.style.marginTop = "8px"; + projectsContent.style.paddingLeft = "12px"; + + projectsContent.createEl("p").textContent = "• {project} - First project title"; + projectsContent.createEl("p").textContent = "• {project_url} - Project URL"; + projectsContent.createEl("p").textContent = "• {project_status} - Status field value (e.g., 'In Progress')"; + projectsContent.createEl("p").textContent = "• {project_priority} - Priority field value"; + projectsContent.createEl("p").textContent = "• {project_iteration} - Current iteration/sprint name"; + projectsContent.createEl("p").textContent = "• {project_iteration_start} - Iteration start date"; + projectsContent.createEl("p").textContent = "• {project_field:FieldName} - Any custom field by name"; + projectsContent.createEl("p").textContent = "• {projects} - All projects (comma-separated)"; + projectsContent.createEl("p").textContent = "• {projects_yaml} - All projects as YAML array"; + // Repositories Section const repoContainer = containerEl.createDiv("github-issues-settings-group"); repoContainer.style.marginTop = "30px"; @@ -1685,4 +1825,255 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { errorBadge.setText("Error validating token"); } } + + /** + * Render the list of tracked projects + */ + private renderTrackedProjects(container: HTMLElement): void { + const projects = this.plugin.settings.trackedProjects; + + if (!projects || projects.length === 0) { + const emptyMessage = container.createEl("p", { + text: "No projects loaded. Click 'Load Projects' to fetch available projects from your tracked repositories.", + cls: "github-issues-empty-message", + }); + emptyMessage.style.color = "var(--text-muted)"; + emptyMessage.style.fontStyle = "italic"; + return; + } + + const enabledCount = projects.filter(p => p.enabled).length; + + const headerContainer = container.createDiv("github-issues-project-list-header"); + headerContainer.style.display = "flex"; + headerContainer.style.justifyContent = "space-between"; + headerContainer.style.alignItems = "center"; + headerContainer.style.marginBottom = "8px"; + + const headerText = headerContainer.createEl("span", { + text: `${enabledCount} of ${projects.length} projects enabled`, + }); + headerText.style.fontWeight = "500"; + + const selectAllContainer = headerContainer.createDiv(); + const selectAllBtn = selectAllContainer.createEl("button", { + text: enabledCount === 0 ? "Enable All" : "Disable All", + cls: "github-issues-select-toggle-btn", + }); + selectAllBtn.style.fontSize = "12px"; + selectAllBtn.style.padding = "2px 8px"; + + selectAllBtn.onclick = async () => { + const newState = enabledCount === 0; + for (const project of this.plugin.settings.trackedProjects) { + project.enabled = newState; + } + await this.plugin.saveSettings(); + + // Re-render + container.empty(); + this.renderTrackedProjects(container); + }; + + const listContainer = container.createDiv("github-issues-project-items"); + listContainer.style.maxHeight = "300px"; + listContainer.style.overflowY = "auto"; + listContainer.style.border = "1px solid var(--background-modifier-border)"; + listContainer.style.borderRadius = "4px"; + listContainer.style.padding = "4px"; + + // Group projects by owner + const projectsByOwner: Record = {}; + for (const project of projects) { + if (!projectsByOwner[project.owner]) { + projectsByOwner[project.owner] = []; + } + projectsByOwner[project.owner].push(project); + } + + for (const owner of Object.keys(projectsByOwner).sort()) { + const ownerProjects = projectsByOwner[owner]; + + const ownerHeader = listContainer.createDiv("github-issues-project-owner-header"); + ownerHeader.style.padding = "6px 8px"; + ownerHeader.style.fontWeight = "500"; + ownerHeader.style.backgroundColor = "var(--background-secondary)"; + ownerHeader.style.borderRadius = "4px"; + ownerHeader.style.marginTop = "4px"; + ownerHeader.textContent = owner; + + for (const project of ownerProjects) { + const projectItem = listContainer.createDiv("github-issues-project-item"); + projectItem.style.display = "flex"; + projectItem.style.alignItems = "center"; + projectItem.style.padding = "4px 8px"; + projectItem.style.cursor = "pointer"; + + projectItem.onmouseenter = () => { + projectItem.style.backgroundColor = "var(--background-modifier-hover)"; + }; + projectItem.onmouseleave = () => { + projectItem.style.backgroundColor = ""; + }; + + const checkbox = projectItem.createEl("input", { + type: "checkbox", + }); + checkbox.checked = project.enabled; + checkbox.style.marginRight = "8px"; + + const labelContainer = projectItem.createDiv(); + labelContainer.style.flex = "1"; + + const titleEl = labelContainer.createEl("span", { + text: project.title, + }); + + const numberEl = labelContainer.createEl("span", { + text: ` #${project.number}`, + }); + numberEl.style.color = "var(--text-muted)"; + numberEl.style.fontSize = "12px"; + + const onToggle = async () => { + project.enabled = checkbox.checked; + await this.plugin.saveSettings(); + + // Update header text + const newEnabledCount = this.plugin.settings.trackedProjects.filter(p => p.enabled).length; + headerText.textContent = `${newEnabledCount} of ${projects.length} projects enabled`; + selectAllBtn.textContent = newEnabledCount === 0 ? "Enable All" : "Disable All"; + }; + + checkbox.onchange = onToggle; + projectItem.onclick = (e) => { + if (e.target !== checkbox) { + checkbox.checked = !checkbox.checked; + onToggle(); + } + }; + } + } + } + + /** + * Load projects from all tracked repositories + */ + private async loadProjectsFromRepositories(): Promise { + if (!this.plugin.gitHubClient) { + throw new Error("GitHub client not initialized"); + } + + if (this.plugin.settings.repositories.length === 0) { + this.plugin.showNotice("No repositories tracked. Please add repositories first before loading projects.", "warning"); + return; + } + + this.plugin.showNotice(`[Projects] Starting to load projects from ${this.plugin.settings.repositories.length} repositories`, "debug"); + + const allProjects: Map = new Map(); + let reposAttempted = 0; + let reposFailed = 0; + + for (const repo of this.plugin.settings.repositories) { + const [owner, repoName] = repo.repository.split("/"); + if (!owner || !repoName) continue; + reposAttempted++; + + try { + const projects = await this.plugin.gitHubClient.fetchProjectsForRepository(owner, repoName); + + for (const project of projects) { + if (!allProjects.has(project.id)) { + allProjects.set(project.id, { + id: project.id, + title: project.title, + number: project.number, + url: project.url, + owner: owner, + }); + } + } + } catch (error) { + reposFailed++; + console.error(`Error fetching projects for ${repo.repository}:`, error); + } + } + + // Merge with existing tracked projects (preserve enabled state) + const existingProjects = new Map( + this.plugin.settings.trackedProjects.map(p => [p.id, p]) + ); + + const newTrackedProjects: typeof this.plugin.settings.trackedProjects = []; + + for (const [id, project] of allProjects) { + const existing = existingProjects.get(id); + newTrackedProjects.push({ + id: project.id, + title: project.title, + number: project.number, + url: project.url, + owner: project.owner, + enabled: existing?.enabled ?? true, // Default to enabled for new projects + }); + } + + this.plugin.settings.trackedProjects = newTrackedProjects; + await this.plugin.saveSettings(); + + if (newTrackedProjects.length === 0 && reposAttempted > 0 && reposFailed > 0) { + new Notice( + `No projects loaded. Failed to fetch from ${reposFailed}/${reposAttempted} repositories. Check your GitHub token has Projects access (e.g. read:project) and that you can access the repos/projects.`, + ); + } else { + new Notice(`Found ${newTrackedProjects.length} projects`); + } + } + + /** + * Load projects directly from a specific repository + */ + private async loadProjectsFromDirectRepository(owner: string, repoName: string): Promise { + if (!this.plugin.gitHubClient) { + throw new Error("GitHub client not initialized"); + } + + this.plugin.showNotice(`[Projects] Loading projects from ${owner}/${repoName}`, "debug"); + + try { + const projects = await this.plugin.gitHubClient.fetchProjectsForRepository(owner, repoName); + + // Merge with existing tracked projects (preserve enabled state) + const existingProjects = new Map( + this.plugin.settings.trackedProjects.map(p => [p.id, p]) + ); + + const newTrackedProjects: typeof this.plugin.settings.trackedProjects = [...this.plugin.settings.trackedProjects]; + + for (const project of projects) { + const existing = existingProjects.get(project.id); + if (!existing) { + // Add new project + newTrackedProjects.push({ + id: project.id, + title: project.title, + number: project.number, + url: project.url, + owner: owner, + enabled: true, // Default to enabled for new projects + }); + } + } + + this.plugin.settings.trackedProjects = newTrackedProjects; + await this.plugin.saveSettings(); + + const newProjectsCount = projects.length - (existingProjects.size - newTrackedProjects.length + projects.length); + new Notice(`Found ${projects.length} projects from ${owner}/${repoName}`); + } catch (error) { + console.error(`Error fetching projects for ${owner}/${repoName}:`, error); + throw new Error(`Failed to load projects from ${owner}/${repoName}: ${error}`); + } + } } diff --git a/src/types.ts b/src/types.ts index bea7c7a..9c0bfd8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,62 @@ export interface RepositoryTracking { escapeHashTags: boolean; } +// Basic project info for selection UI +export interface ProjectInfo { + id: string; + title: string; + number: number; + url: string; + closed: boolean; + owner?: string; // Owner (user or org) of the project +} + +// Tracked project configuration +export interface TrackedProject { + id: string; + title: string; + number: number; + url: string; + owner: string; // User or organization that owns the project + enabled: boolean; // Whether this project is being tracked +} + +// GitHub Projects v2 types +export interface ProjectFieldValue { + fieldName: string; + type: 'text' | 'number' | 'date' | 'single_select' | 'iteration' | 'user' | 'labels'; + value: string | number | null; + startDate?: string; + duration?: number; + users?: string[]; + labels?: string[]; +} + +export interface ProjectData { + projectId: string; + projectTitle: string; + projectNumber: number; + projectUrl: string; + status?: string; + priority?: string; + iteration?: { + title: string; + startDate: string; + duration: number; + }; + customFields: Record; +} + +export interface IssueWithProjectData { + issue: any; + projectData: ProjectData[]; +} + +export interface PullRequestWithProjectData { + pullRequest: any; + projectData: ProjectData[]; +} + export interface GlobalDefaults { issueUpdateMode: "none" | "update" | "append"; allowDeleteIssue: boolean; @@ -68,6 +124,9 @@ export interface GitHubTrackerSettings { backgroundSyncInterval: number; // in minutes cleanupClosedIssuesDays: number; globalDefaults: GlobalDefaults; + // GitHub Projects settings + enableProjectTracking: boolean; + trackedProjects: TrackedProject[]; } export const DEFAULT_GLOBAL_DEFAULTS: GlobalDefaults = { @@ -100,6 +159,8 @@ export const DEFAULT_SETTINGS: GitHubTrackerSettings = { backgroundSyncInterval: 30, cleanupClosedIssuesDays: 30, globalDefaults: DEFAULT_GLOBAL_DEFAULTS, + enableProjectTracking: false, + trackedProjects: [], }; // Default repository tracking settings diff --git a/src/util/templateUtils.ts b/src/util/templateUtils.ts index 4bd8a77..2af1d77 100644 --- a/src/util/templateUtils.ts +++ b/src/util/templateUtils.ts @@ -4,6 +4,7 @@ import { format } from "date-fns"; import { escapeBody , escapeYamlString} from "./escapeUtils"; +import { ProjectData } from "../types"; /** * Represents the data available for template replacement @@ -39,6 +40,8 @@ interface TemplateData { isLocked: boolean; lockReason?: string; comments?: string; // Formatted comments section + // GitHub Projects fields + projectData?: ProjectData[]; } /** @@ -196,13 +199,84 @@ export function processTemplate( // Add comments variable replacements["{comments}"] = data.comments || ""; + // Add GitHub Projects variables + if (data.projectData && data.projectData.length > 0) { + const firstProject = data.projectData[0]; + + // Basic project info + replacements["{project}"] = firstProject.projectTitle || ""; + replacements["{project_url}"] = firstProject.projectUrl || ""; + replacements["{project_number}"] = firstProject.projectNumber?.toString() || ""; + replacements["{project_status}"] = firstProject.status || ""; + replacements["{project_priority}"] = firstProject.priority || ""; + + // Iteration info + if (firstProject.iteration) { + replacements["{project_iteration}"] = firstProject.iteration.title || ""; + replacements["{project_iteration_start}"] = firstProject.iteration.startDate || ""; + replacements["{project_iteration_duration}"] = firstProject.iteration.duration?.toString() || ""; + } else { + replacements["{project_iteration}"] = ""; + replacements["{project_iteration_start}"] = ""; + replacements["{project_iteration_duration}"] = ""; + } + + // All projects (for items in multiple projects) + replacements["{projects}"] = data.projectData.map(p => p.projectTitle).join(", "); + replacements["{projects_yaml}"] = `[${data.projectData.map(p => `"${p.projectTitle}"`).join(", ")}]`; + + // Custom fields as YAML + const customFieldsYaml = Object.entries(firstProject.customFields) + .map(([name, field]) => ` ${name}: "${field.value}"`) + .join("\n"); + replacements["{project_fields}"] = customFieldsYaml ? `\n${customFieldsYaml}` : ""; + } else { + // Empty project variables when no project data + replacements["{project}"] = ""; + replacements["{project_url}"] = ""; + replacements["{project_number}"] = ""; + replacements["{project_status}"] = ""; + replacements["{project_priority}"] = ""; + replacements["{project_iteration}"] = ""; + replacements["{project_iteration_start}"] = ""; + replacements["{project_iteration_duration}"] = ""; + replacements["{projects}"] = ""; + replacements["{projects_yaml}"] = "[]"; + replacements["{project_fields}"] = ""; + } + // Replace all variables for (const [placeholder, value] of Object.entries(replacements)) { result = result.replace(new RegExp(escapeRegExp(placeholder), "g"), value); } + // Process dynamic project field access: {project_field:FieldName} + result = processProjectFieldAccess(result, data.projectData); + return result; -}/** +} + +/** + * Process dynamic project field access patterns like {project_field:FieldName} + */ +function processProjectFieldAccess(template: string, projectData?: ProjectData[]): string { + if (!projectData || projectData.length === 0) { + // Remove all project_field patterns with empty string + return template.replace(/\{project_field:([^}]+)\}/g, ""); + } + + const firstProject = projectData[0]; + + return template.replace(/\{project_field:([^}]+)\}/g, (match, fieldName) => { + const field = firstProject.customFields[fieldName]; + if (field) { + return String(field.value || ""); + } + return ""; + }); +} + +/** * Process a template string for filename generation (with sanitization) * @param template The template string for filename * @param data The data to use for replacement @@ -281,6 +355,12 @@ function getVariableValue(variableName: string, data: TemplateData): string | un case "headBranch": return data.headBranch; case "merged": return data.merged ? "true" : undefined; case "mergeable": return data.mergeable !== undefined ? "true" : undefined; + // Project-related conditionals + case "project": return data.projectData && data.projectData.length > 0 ? data.projectData[0].projectTitle : undefined; + case "project_status": return data.projectData && data.projectData.length > 0 ? data.projectData[0].status : undefined; + case "project_priority": return data.projectData && data.projectData.length > 0 ? data.projectData[0].priority : undefined; + case "project_iteration": return data.projectData && data.projectData.length > 0 && data.projectData[0].iteration ? data.projectData[0].iteration.title : undefined; + case "projects": return data.projectData && data.projectData.length > 0 ? "true" : undefined; default: return undefined; } } @@ -289,6 +369,11 @@ function getVariableValue(variableName: string, data: TemplateData): string | un * Create template data from an issue object * @param issue The issue data from GitHub API * @param repository The repository string (owner/repo) + * @param comments Array of comments + * @param dateFormat Date format string + * @param escapeMode Escape mode for text + * @param escapeHashTags Whether to escape hash tags + * @param projectData Optional project data from GitHub Projects * @returns TemplateData object */ export function createIssueTemplateData( @@ -297,7 +382,8 @@ export function createIssueTemplateData( comments: any[] = [], dateFormat: string = "", escapeMode: "disabled" | "normal" | "strict" | "veryStrict" = "normal", - escapeHashTags: boolean = false + escapeHashTags: boolean = false, + projectData?: ProjectData[] ): TemplateData { const [owner, repoName] = repository.split("/"); @@ -327,7 +413,8 @@ export function createIssueTemplateData( commentsCount: issue.comments || 0, isLocked: issue.locked || false, lockReason: issue.active_lock_reason || "", - comments: formatComments(comments, dateFormat, escapeMode, escapeHashTags) + comments: formatComments(comments, dateFormat, escapeMode, escapeHashTags), + projectData: projectData, }; } @@ -335,6 +422,11 @@ export function createIssueTemplateData( * Create template data from a pull request object * @param pr The pull request data from GitHub API * @param repository The repository string (owner/repo) + * @param comments Array of comments + * @param dateFormat Date format string + * @param escapeMode Escape mode for text + * @param escapeHashTags Whether to escape hash tags + * @param projectData Optional project data from GitHub Projects * @returns TemplateData object */ export function createPullRequestTemplateData( @@ -343,7 +435,8 @@ export function createPullRequestTemplateData( comments: any[] = [], dateFormat: string = "", escapeMode: "disabled" | "normal" | "strict" | "veryStrict" = "normal", - escapeHashTags: boolean = false + escapeHashTags: boolean = false, + projectData?: ProjectData[] ): TemplateData { const [owner, repoName] = repository.split("/"); @@ -379,7 +472,8 @@ export function createPullRequestTemplateData( merged: pr.merged || false, baseBranch: pr.base?.ref, headBranch: pr.head?.ref, - comments: formatComments(comments, dateFormat, escapeMode, escapeHashTags) + comments: formatComments(comments, dateFormat, escapeMode, escapeHashTags), + projectData: projectData, }; } diff --git a/styles.css b/styles.css index 0f85ee5..532aac7 100644 --- a/styles.css +++ b/styles.css @@ -1761,3 +1761,155 @@ button:disabled { color: var(--text-normal); font-size: 0.9em; } + +/* Kanban View Styles */ +.github-kanban-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.github-kanban-header h2 { + margin: 0; + font-size: 1.5em; + font-weight: 600; +} + +.github-kanban-refresh-btn { + padding: 6px 12px; + background-color: var(--interactive-normal); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; +} + +.github-kanban-refresh-btn:hover { + background-color: var(--interactive-hover); +} + +.github-kanban-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 40px; +} + +.github-kanban-project { + margin-bottom: 30px; +} + +.github-kanban-project h3 { + margin: 0 0 15px 0; + font-size: 1.2em; + font-weight: 600; + color: var(--text-normal); + padding-bottom: 5px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.github-kanban-board { + display: flex; + gap: 15px; + overflow-x: auto; + padding: 10px 0; +} + +.github-kanban-column { + flex: 0 0 280px; + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 12px; + min-height: 400px; +} + +.github-kanban-column h4 { + margin: 0 0 12px 0; + font-size: 1em; + font-weight: 600; + color: var(--text-normal); + text-align: center; + padding: 8px; + background-color: var(--background-secondary); + border-radius: 4px; +} + +.github-kanban-items { + display: flex; + flex-direction: column; + gap: 8px; +} + +.github-kanban-item { + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 12px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.github-kanban-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + border-color: var(--interactive-accent); +} + +.github-kanban-item-title { + font-weight: 600; + color: var(--text-normal); + margin-bottom: 6px; + line-height: 1.3; + font-size: 0.95em; +} + +.github-kanban-item-meta { + font-size: 0.8em; + color: var(--text-muted); + margin-bottom: 8px; +} + +.github-kanban-item-labels { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +.github-kanban-label { + padding: 2px 6px; + border-radius: 10px; + font-size: 0.75em; + font-weight: 500; + color: white; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); +} + +/* Dark theme adjustments */ +.theme-dark .github-kanban-item { + background-color: var(--background-primary); +} + +.theme-dark .github-kanban-column h4 { + background-color: var(--background-primary); +} + +/* Responsive design */ +@media (max-width: 768px) { + .github-kanban-board { + flex-direction: column; + gap: 10px; + } + + .github-kanban-column { + flex: none; + width: 100%; + min-height: 300px; + } +} From ea5154d50fc26dc23885568bedf70041e24b7f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Platte?= Date: Mon, 29 Dec 2025 22:05:38 +0100 Subject: [PATCH 2/4] feat: GitHub Projects integration #30 --- src/file-manager.ts | 411 +++++++++++++- src/folder-path-manager.ts | 34 +- src/github-client.ts | 185 +++++- src/github-graphql.ts | 416 ++++---------- src/issue-file-manager.ts | 12 +- src/kanban-view.ts | 545 +++++++++++------- src/main.ts | 112 ++++ src/pr-file-manager.ts | 12 +- src/settings-tab.ts | 802 ++++++++++++++++++--------- src/settings/project-list-manager.ts | 396 +++++++++++++ src/settings/project-renderer.ts | 527 ++++++++++++++++++ src/types.ts | 33 +- src/util/persistUtils.ts | 31 +- styles.css | 513 +++++++++++++++-- 14 files changed, 3156 insertions(+), 873 deletions(-) create mode 100644 src/settings/project-list-manager.ts create mode 100644 src/settings/project-renderer.ts diff --git a/src/file-manager.ts b/src/file-manager.ts index a1b8080..efdfc5c 100644 --- a/src/file-manager.ts +++ b/src/file-manager.ts @@ -1,20 +1,32 @@ -import { App } from "obsidian"; -import { GitHubTrackerSettings, RepositoryTracking } from "./types"; +import { App, TFile } from "obsidian"; +import { GitHubTrackerSettings, RepositoryTracking, TrackedProject, ProjectData } from "./types"; import { NoticeManager } from "./notice-manager"; import { GitHubClient } from "./github-client"; import { IssueFileManager } from "./issue-file-manager"; import { PullRequestFileManager } from "./pr-file-manager"; import { FilterManager } from "./filter-manager"; import { FolderPathManager } from "./folder-path-manager"; +import { FileHelpers } from "./util/file-helpers"; +import { escapeBody, escapeYamlString } from "./util/escapeUtils"; +import { format } from "date-fns"; +import { + createIssueTemplateData, + createPullRequestTemplateData, + processContentTemplate, + processFilenameTemplate, + formatComments +} from "./util/templateUtils"; +import { extractPersistBlocks, mergePersistBlocks } from "./util/persistUtils"; export class FileManager { private issueFileManager: IssueFileManager; private prFileManager: PullRequestFileManager; private filterManager: FilterManager; private folderPathManager: FolderPathManager; + private fileHelpers: FileHelpers; constructor( - app: App, + private app: App, private settings: GitHubTrackerSettings, private noticeManager: NoticeManager, gitHubClient: GitHubClient, @@ -23,6 +35,7 @@ export class FileManager { this.prFileManager = new PullRequestFileManager(app, settings, noticeManager, gitHubClient); this.filterManager = new FilterManager(gitHubClient); this.folderPathManager = new FolderPathManager(); + this.fileHelpers = new FileHelpers(app, noticeManager); } /** @@ -83,4 +96,396 @@ export class FileManager { this.noticeManager.error("Error cleaning up empty folders", error); } } + + /** + * Create files for project items (issues and PRs from a GitHub Project) + */ + public async createProjectItemFiles( + project: TrackedProject, + items: any[], + ): Promise { + const issueFolderPath = this.folderPathManager.getProjectIssueFolderPath(project); + const prFolderPath = this.folderPathManager.getProjectPullRequestFolderPath(project); + + if (!issueFolderPath && !prFolderPath) { + this.noticeManager.debug(`No folder configured for project ${project.title}`); + return; + } + + let createdCount = 0; + let skippedNoContent = 0; + let skippedNotIssueOrPr = 0; + let skippedHiddenStatus = 0; + + const hiddenStatuses = new Set(project.hiddenStatuses || []); + const skipHidden = project.skipHiddenStatusesOnSync && hiddenStatuses.size > 0; + + for (const item of items) { + const content = item.content; + if (!content) { + skippedNoContent++; + continue; + } + + const isIssue = content.url?.includes('/issues/'); + const isPullRequest = content.url?.includes('/pull/'); + if (!isIssue && !isPullRequest) { + skippedNotIssueOrPr++; + continue; + } + + // Extract status first to check if it should be skipped + let status = ""; + if (item.fieldValues?.nodes) { + for (const fieldValue of item.fieldValues.nodes) { + if (fieldValue.field?.name === "Status" && fieldValue.name) { + status = fieldValue.name; + break; + } + } + } + + // Skip items with hidden statuses if enabled + if (skipHidden && hiddenStatuses.has(status || "No Status")) { + skippedHiddenStatus++; + continue; + } + + const folderPath = isIssue ? issueFolderPath : prFolderPath; + if (!folderPath) continue; + + await this.fileHelpers.ensureFolderExists(folderPath); + + const repository = this.extractRepositoryFromUrl(content.url) || `${project.owner}/unknown`; + const projectData = this.convertFieldValuesToProjectData(project, status, item.fieldValues?.nodes || []); + + const templateData = isIssue + ? createIssueTemplateData( + this.convertToIssueFormat(content), + repository, + [], + this.settings.dateFormat, + this.settings.escapeMode, + this.settings.escapeHashTags, + [projectData] + ) + : createPullRequestTemplateData( + this.convertToPullRequestFormat(content), + repository, + [], + this.settings.dateFormat, + this.settings.escapeMode, + this.settings.escapeHashTags, + [projectData] + ); + + const filenameTemplate = isIssue + ? (project.issueNoteTemplate || "Issue - {number} - {title}") + : (project.pullRequestNoteTemplate || "PR - {number} - {title}"); + + const baseFileName = processFilenameTemplate(filenameTemplate, templateData, this.settings.dateFormat); + const fileName = `${baseFileName}.md`; + const filePath = `${folderPath}/${fileName}`; + + const existingFile = this.app.vault.getAbstractFileByPath(filePath); + let fileContent = await this.generateProjectItemContent( + content, + project, + status, + isIssue, + item.fieldValues?.nodes || [] + ); + + if (existingFile && existingFile instanceof TFile) { + const existingContent = await this.app.vault.read(existingFile); + const persistBlocks = extractPersistBlocks(existingContent); + if (persistBlocks.size > 0) { + fileContent = mergePersistBlocks(fileContent, existingContent, persistBlocks); + this.noticeManager.debug( + `Restored ${persistBlocks.size} persist block(s) for project item #${content.number}` + ); + } + await this.app.vault.modify(existingFile, fileContent); + } else { + await this.app.vault.create(filePath, fileContent); + } + createdCount++; + } + + this.noticeManager.debug( + `Project ${project.title}: Created ${createdCount} files, skipped ${skippedNoContent} drafts, ${skippedNotIssueOrPr} other, ${skippedHiddenStatus} hidden` + ); + } + + /** + * Generate content for a project item file + */ + private async generateProjectItemContent( + content: any, + project: TrackedProject, + status: string, + isIssue: boolean, + fieldValues: any[], + ): Promise { + const shouldEscapeHashTags = this.settings.escapeHashTags; + + // Check if custom template is enabled + const useCustomTemplate = isIssue + ? project.useCustomIssueContentTemplate + : project.useCustomPullRequestContentTemplate; + const templatePath = isIssue + ? project.issueContentTemplate + : project.pullRequestContentTemplate; + + if (useCustomTemplate && templatePath) { + const templateContent = await this.fileHelpers.loadTemplateContent(templatePath); + if (templateContent) { + // Convert project item data to template-compatible format + const projectData = this.convertFieldValuesToProjectData(project, status, fieldValues); + + // Create a pseudo-repository string for template compatibility + const repository = this.extractRepositoryFromUrl(content.url) || `${project.owner}/unknown`; + + // Create template data based on item type + const templateData = isIssue + ? createIssueTemplateData( + this.convertToIssueFormat(content), + repository, + [], // No comments for project items currently + this.settings.dateFormat, + this.settings.escapeMode, + shouldEscapeHashTags, + [projectData] + ) + : createPullRequestTemplateData( + this.convertToPullRequestFormat(content), + repository, + [], // No comments for project items currently + this.settings.dateFormat, + this.settings.escapeMode, + shouldEscapeHashTags, + [projectData] + ); + + return processContentTemplate(templateContent, templateData, this.settings.dateFormat); + } + } + + // Fallback to default format + return this.generateDefaultProjectItemContent(content, project, status, isIssue, fieldValues); + } + + /** + * Generate default content for project items (same format as repo issues/PRs) + */ + private generateDefaultProjectItemContent( + content: any, + project: TrackedProject, + status: string, + isIssue: boolean, + fieldValues: any[], + ): string { + const shouldEscapeHashTags = this.settings.escapeHashTags; + const dateFormat = this.settings.dateFormat; + + // Format date helper + const formatDate = (dateStr: string | undefined): string => { + if (!dateStr) return ""; + const date = new Date(dateStr); + if (dateFormat !== "") { + return format(date, dateFormat); + } + return date.toLocaleString(); + }; + + // Build frontmatter - same format as repo issues + const title = escapeYamlString(content.title || ""); + const createdAt = formatDate(content.createdAt); + const updatedAt = formatDate(content.updatedAt); + const author = content.author?.login || ""; + const assignees = content.assignees?.nodes?.map((a: any) => `"${a.login}"`) || []; + const labels = content.labels?.nodes?.map((l: any) => `"${l.name}"`) || []; + + let frontmatter = `--- +title: "${title}" +number: ${content.number} +state: "${content.state || "open"}" +type: "${isIssue ? "issue" : "pr"}" +created: "${createdAt}" +updated: "${updatedAt}" +url: "${content.url || ""}" +opened_by: "${author}" +assignees: [${assignees.join(", ")}] +labels: [${labels.join(", ")}] +project: "${project.title}" +project_status: "${status}"`; + + // Add PR-specific fields + if (!isIssue) { + const reviewers = content.reviewRequests?.nodes?.map((r: any) => `"${r.requestedReviewer?.login || ""}"`) || []; + frontmatter += ` +requested_reviewers: [${reviewers.join(", ")}]`; + } + + frontmatter += ` +--- + +# ${escapeBody(content.title || "", this.settings.escapeMode, false)} +${ + content.body + ? escapeBody(content.body, this.settings.escapeMode, shouldEscapeHashTags) + : "_No description provided._" +}`; + + return frontmatter; + } + + /** + * Convert GraphQL field values to ProjectData format for template processing + */ + private convertFieldValuesToProjectData( + project: TrackedProject, + status: string, + fieldValues: any[], + ): ProjectData { + const customFields: Record = {}; + let priority: string | undefined; + let iteration: { title: string; startDate: string; duration: number } | undefined; + + for (const fieldValue of fieldValues) { + if (!fieldValue.field?.name) continue; + const fieldName = fieldValue.field.name; + + if (fieldName.toLowerCase() === "priority" && fieldValue.name) { + priority = fieldValue.name; + } else if (fieldValue.title !== undefined && fieldValue.startDate !== undefined) { + // Iteration field + iteration = { + title: fieldValue.title, + startDate: fieldValue.startDate, + duration: fieldValue.duration || 14, + }; + } + + // Add to custom fields + let value: string | number | null = null; + if (fieldValue.text !== undefined) { + value = fieldValue.text; + } else if (fieldValue.name !== undefined) { + value = fieldValue.name; + } else if (fieldValue.number !== undefined) { + value = fieldValue.number; + } else if (fieldValue.date !== undefined) { + value = fieldValue.date; + } + + if (value !== null) { + customFields[fieldName] = { + fieldName, + type: this.inferFieldType(fieldValue), + value, + }; + } + } + + return { + projectId: project.id, + projectTitle: project.title, + projectNumber: project.number, + projectUrl: project.url, + status, + priority, + iteration, + customFields, + }; + } + + /** + * Infer field type from field value structure + */ + private inferFieldType(fieldValue: any): 'text' | 'number' | 'date' | 'single_select' | 'iteration' | 'user' | 'labels' { + if (fieldValue.title !== undefined && fieldValue.startDate !== undefined) { + return 'iteration'; + } + if (fieldValue.users?.nodes?.length > 0) { + return 'user'; + } + if (fieldValue.labels?.nodes?.length > 0) { + return 'labels'; + } + if (fieldValue.name !== undefined) { + return 'single_select'; + } + if (fieldValue.number !== undefined) { + return 'number'; + } + if (fieldValue.date !== undefined) { + return 'date'; + } + return 'text'; + } + + /** + * Extract repository (owner/repo) from GitHub URL + */ + private extractRepositoryFromUrl(url: string): string | null { + if (!url) return null; + const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)\//); + if (match) { + return `${match[1]}/${match[2]}`; + } + return null; + } + + /** + * Convert GraphQL project item content to issue format for template processing + */ + private convertToIssueFormat(content: any): any { + return { + title: content.title, + number: content.number, + state: content.state || "open", + created_at: content.createdAt, + updated_at: content.updatedAt, + closed_at: content.closedAt, + html_url: content.url, + body: content.body || "", + user: content.author, + assignee: content.assignees?.nodes?.[0] || null, + assignees: content.assignees?.nodes || [], + labels: content.labels?.nodes?.map((l: any) => ({ name: l.name })) || [], + milestone: content.milestone, + comments: 0, + locked: false, + }; + } + + /** + * Convert GraphQL project item content to pull request format for template processing + */ + private convertToPullRequestFormat(content: any): any { + return { + title: content.title, + number: content.number, + state: content.state || "open", + created_at: content.createdAt, + updated_at: content.updatedAt, + closed_at: content.closedAt, + merged_at: content.mergedAt, + html_url: content.url, + body: content.body || "", + user: content.author, + assignee: content.assignees?.nodes?.[0] || null, + assignees: content.assignees?.nodes || [], + requested_reviewers: content.reviewRequests?.nodes?.map((r: any) => r.requestedReviewer) || [], + labels: content.labels?.nodes?.map((l: any) => ({ name: l.name })) || [], + milestone: content.milestone, + comments: 0, + locked: false, + merged: content.merged || false, + mergeable: content.mergeable, + base: content.baseRefName ? { ref: content.baseRefName } : undefined, + head: content.headRefName ? { ref: content.headRefName } : undefined, + }; + } } diff --git a/src/folder-path-manager.ts b/src/folder-path-manager.ts index 3fbb9e3..fb5e06f 100644 --- a/src/folder-path-manager.ts +++ b/src/folder-path-manager.ts @@ -1,4 +1,4 @@ -import { RepositoryTracking } from "./types"; +import { RepositoryTracking, TrackedProject } from "./types"; export class FolderPathManager { /** @@ -20,4 +20,36 @@ export class FolderPathManager { } return `${repo.pullRequestFolder}/${ownerCleaned}/${repoCleaned}`; } + + public getProjectIssueFolderPath(project: TrackedProject): string { + if (project.useCustomIssueFolder && project.customIssueFolder?.trim()) { + return this.processProjectFolderTemplate(project.customIssueFolder.trim(), project); + } + const folder = project.issueFolder?.trim() || "GitHub/{project}"; + return this.processProjectFolderTemplate(folder, project); + } + + public getProjectPullRequestFolderPath(project: TrackedProject): string | null { + if (project.useCustomPullRequestFolder && project.customPullRequestFolder?.trim()) { + return this.processProjectFolderTemplate(project.customPullRequestFolder.trim(), project); + } + if (project.pullRequestFolder?.trim()) { + return this.processProjectFolderTemplate(project.pullRequestFolder, project); + } + return null; + } + + public processProjectFolderTemplate(folderTemplate: string, project: TrackedProject): string { + return folderTemplate + .replace(/\{project\}/g, this.sanitizeFolderPart(project.title)) + .replace(/\{owner\}/g, this.sanitizeFolderPart(project.owner)) + .replace(/\{project_number\}/g, project.number.toString()); + } + + private sanitizeFolderPart(str: string): string { + return str + .replace(/[<>:"|?*\\]/g, "-") + .replace(/\.\./g, ".") + .trim(); + } } diff --git a/src/github-client.ts b/src/github-client.ts index 06234cf..dec4632 100644 --- a/src/github-client.ts +++ b/src/github-client.ts @@ -1,4 +1,4 @@ -import { GitHubTrackerSettings, ProjectData, ProjectFieldValue, ProjectInfo } from "./types"; +import { GitHubTrackerSettings, ProjectData, ProjectFieldValue, ProjectInfo, ProjectStatusOption } from "./types"; import { Octokit } from "octokit"; import { NoticeManager } from "./notice-manager"; import { @@ -8,6 +8,7 @@ import { GET_ORGANIZATION_PROJECTS, GET_USER_PROJECTS, GET_PROJECT_ITEMS, + GET_PROJECT_FIELDS, parseItemProjectData, ProjectItemData, } from "./github-graphql"; @@ -689,6 +690,130 @@ export class GitHubClient { }); } + /** + * Fetch all available projects for the authenticated user (user + org projects) + */ + public async fetchAllAvailableProjects(): Promise { + if (!this.octokit) { + return []; + } + + const projects: ProjectInfo[] = []; + const seenIds = new Set(); + + try { + // Get authenticated user + const user = await this.fetchAuthenticatedUser(); + if (!user) { + this.noticeManager.error("Could not get authenticated user"); + return []; + } + + // Fetch user's own projects + try { + let hasNextPage = true; + let cursor: string | null = null; + + while (hasNextPage) { + const userResponse: any = await this.octokit.graphql( + GET_USER_PROJECTS, + { + user: user, + first: 50, + after: cursor, + }, + ); + + if (userResponse?.user?.projectsV2?.nodes) { + for (const node of userResponse.user.projectsV2.nodes) { + if (!seenIds.has(node.id)) { + seenIds.add(node.id); + projects.push({ + id: node.id, + title: node.title, + number: node.number, + url: node.url, + closed: node.closed, + owner: user, + }); + } + } + } + + hasNextPage = userResponse?.user?.projectsV2?.pageInfo?.hasNextPage ?? false; + cursor = userResponse?.user?.projectsV2?.pageInfo?.endCursor ?? null; + } + } catch (error) { + this.noticeManager.debug(`Error fetching user projects: ${error}`); + } + + // Fetch organization projects + try { + let allOrgs: { login: string }[] = []; + let orgsPage = 1; + let hasMoreOrgs = true; + + while (hasMoreOrgs) { + const { data: orgs } = await this.octokit.rest.orgs.listForAuthenticatedUser({ + per_page: 100, + page: orgsPage, + }); + + allOrgs = [...allOrgs, ...orgs]; + hasMoreOrgs = orgs.length === 100; + orgsPage++; + } + + for (const org of allOrgs) { + try { + let hasNextPage = true; + let cursor: string | null = null; + + while (hasNextPage) { + const orgResponse: any = await this.octokit.graphql( + GET_ORGANIZATION_PROJECTS, + { + org: org.login, + first: 50, + after: cursor, + }, + ); + + if (orgResponse?.organization?.projectsV2?.nodes) { + for (const node of orgResponse.organization.projectsV2.nodes) { + if (!seenIds.has(node.id)) { + seenIds.add(node.id); + projects.push({ + id: node.id, + title: node.title, + number: node.number, + url: node.url, + closed: node.closed, + owner: org.login, + }); + } + } + } + + hasNextPage = orgResponse?.organization?.projectsV2?.pageInfo?.hasNextPage ?? false; + cursor = orgResponse?.organization?.projectsV2?.pageInfo?.endCursor ?? null; + } + } catch (error) { + this.noticeManager.debug(`Error fetching projects for org ${org.login}: ${error}`); + } + } + } catch (error) { + this.noticeManager.debug(`Error fetching organizations: ${error}`); + } + + this.noticeManager.debug(`Found ${projects.length} total projects`); + } catch (error) { + this.noticeManager.error("Error fetching all projects", error); + } + + return projects; + } + /** * Fetch available projects for a repository (includes org projects) */ @@ -702,15 +827,12 @@ export class GitHubClient { const projects: ProjectInfo[] = []; - this.noticeManager.debug(`[Projects] Fetching for owner='${owner}', repo='${repo}'`); - try { // First, try to get repository-linked projects let hasNextPage = true; let cursor: string | null = null; while (hasNextPage) { - this.noticeManager.debug(`[Projects] Querying repository projects: owner='${owner}', repo='${repo}', after='${cursor}'`); const response: any = await this.octokit.graphql( GET_REPOSITORY_PROJECTS, { @@ -735,12 +857,10 @@ export class GitHubClient { hasNextPage = response?.repository?.projectsV2?.pageInfo?.hasNextPage ?? false; cursor = response?.repository?.projectsV2?.pageInfo?.endCursor ?? null; - this.noticeManager.debug(`[Projects] Repo projects page: found=${response?.repository?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`); } // Also try to get organization projects if the owner is an org try { - this.noticeManager.debug(`[Projects] Querying org projects: org='${owner}', after='${cursor}'`); hasNextPage = true; cursor = null; @@ -771,17 +891,13 @@ export class GitHubClient { hasNextPage = orgResponse?.organization?.projectsV2?.pageInfo?.hasNextPage ?? false; cursor = orgResponse?.organization?.projectsV2?.pageInfo?.endCursor ?? null; - this.noticeManager.debug(`[Projects] Org projects page: found=${orgResponse?.organization?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`); } - } catch (orgError) { - // Owner is probably a user, not an org - that's fine - this.noticeManager.debug(`Could not fetch org projects for ${owner}: likely a user account`); - this.noticeManager.debug(`[Projects] Org projects error: ${orgError}`); + } catch { + // Owner is probably a user, not an org - try user projects instead } // Also try to get user projects if the owner is a user try { - this.noticeManager.debug(`[Projects] Querying user projects: user='${owner}', after='${cursor}'`); hasNextPage = true; cursor = null; @@ -811,17 +927,14 @@ export class GitHubClient { hasNextPage = userResponse?.user?.projectsV2?.pageInfo?.hasNextPage ?? false; cursor = userResponse?.user?.projectsV2?.pageInfo?.endCursor ?? null; - this.noticeManager.debug(`[Projects] User projects page: found=${userResponse?.user?.projectsV2?.nodes?.length ?? 0}, hasNextPage=${hasNextPage}`); } - } catch (userError) { - this.noticeManager.debug(`Could not fetch user projects for ${owner}: likely an org account`); - this.noticeManager.debug(`[Projects] User projects error: ${userError}`); + } catch { + // Owner is an org, not a user - that's fine } this.noticeManager.debug( `Found ${projects.length} projects for ${owner}/${repo}`, ); - this.noticeManager.debug(`[Projects] Final project count for owner='${owner}', repo='${repo}': ${projects.length}`); } catch (error) { this.noticeManager.debug( `Error fetching projects for ${owner}/${repo}: ${error}`, @@ -887,6 +1000,44 @@ export class GitHubClient { } } + /** + * Fetch status field options for a project (in GitHub's order) + */ + public async fetchProjectStatusOptions(projectId: string): Promise { + if (!this.octokit) { + return []; + } + + try { + const response: any = await this.octokit.graphql(GET_PROJECT_FIELDS, { + projectId, + }); + + if (!response?.node?.fields?.nodes) { + return []; + } + + // Find the Status field (SingleSelectField with name "Status") + for (const field of response.node.fields.nodes) { + if (field.name === 'Status' && field.options) { + return field.options.map((opt: any) => ({ + id: opt.id, + name: opt.name, + color: opt.color, + description: opt.description, + })); + } + } + + return []; + } catch (error) { + this.noticeManager.debug( + `Error fetching status options for project ${projectId}: ${error}`, + ); + return []; + } + } + public dispose(): void { this.octokit = null; this.currentUser = ""; diff --git a/src/github-graphql.ts b/src/github-graphql.ts index f9165e9..0817f48 100644 --- a/src/github-graphql.ts +++ b/src/github-graphql.ts @@ -2,6 +2,54 @@ * GraphQL queries for GitHub Projects v2 API */ +// Shared fragment for project field values - reduces query duplication +const FIELD_VALUES_FRAGMENT = ` +fieldValues(first: 30) { + nodes { + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { name } } + } + ... on ProjectV2ItemFieldNumberValue { + number + field { ... on ProjectV2Field { name } } + } + ... on ProjectV2ItemFieldDateValue { + date + field { ... on ProjectV2Field { name } } + } + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + field { ... on ProjectV2SingleSelectField { name } } + } + ... on ProjectV2ItemFieldIterationValue { + title + startDate + duration + iterationId + field { ... on ProjectV2IterationField { name } } + } + ... on ProjectV2ItemFieldUserValue { + users(first: 10) { nodes { login } } + field { ... on ProjectV2Field { name } } + } + ... on ProjectV2ItemFieldLabelValue { + labels(first: 20) { nodes { name } } + field { ... on ProjectV2Field { name } } + } + } +}`; + +// Shared fragment for project item info +const PROJECT_INFO_FRAGMENT = ` +project { + id + title + number + url +}`; + // Query to get projects linked to a repository export const GET_REPOSITORY_PROJECTS = ` query GetRepositoryProjects($owner: String!, $repo: String!, $first: Int!, $after: String) { @@ -116,84 +164,8 @@ query GetItemProjectData($nodeId: ID!) { projectItems(first: 10) { nodes { id - project { - id - title - number - url - } - fieldValues(first: 30) { - nodes { - ... on ProjectV2ItemFieldTextValue { - text - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldNumberValue { - number - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldDateValue { - date - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldSingleSelectValue { - name - optionId - field { - ... on ProjectV2SingleSelectField { - name - } - } - } - ... on ProjectV2ItemFieldIterationValue { - title - startDate - duration - iterationId - field { - ... on ProjectV2IterationField { - name - } - } - } - ... on ProjectV2ItemFieldUserValue { - users(first: 10) { - nodes { - login - } - } - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldLabelValue { - labels(first: 20) { - nodes { - name - } - } - field { - ... on ProjectV2Field { - name - } - } - } - } - } + ${PROJECT_INFO_FRAGMENT} + ${FIELD_VALUES_FRAGMENT} } } } @@ -201,84 +173,8 @@ query GetItemProjectData($nodeId: ID!) { projectItems(first: 10) { nodes { id - project { - id - title - number - url - } - fieldValues(first: 30) { - nodes { - ... on ProjectV2ItemFieldTextValue { - text - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldNumberValue { - number - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldDateValue { - date - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldSingleSelectValue { - name - optionId - field { - ... on ProjectV2SingleSelectField { - name - } - } - } - ... on ProjectV2ItemFieldIterationValue { - title - startDate - duration - iterationId - field { - ... on ProjectV2IterationField { - name - } - } - } - ... on ProjectV2ItemFieldUserValue { - users(first: 10) { - nodes { - login - } - } - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldLabelValue { - labels(first: 20) { - nodes { - name - } - } - field { - ... on ProjectV2Field { - name - } - } - } - } - } + ${PROJECT_INFO_FRAGMENT} + ${FIELD_VALUES_FRAGMENT} } } } @@ -296,84 +192,8 @@ query GetItemsProjectDataBatch($nodeIds: [ID!]!) { projectItems(first: 10) { nodes { id - project { - id - title - number - url - } - fieldValues(first: 30) { - nodes { - ... on ProjectV2ItemFieldTextValue { - text - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldNumberValue { - number - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldDateValue { - date - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldSingleSelectValue { - name - optionId - field { - ... on ProjectV2SingleSelectField { - name - } - } - } - ... on ProjectV2ItemFieldIterationValue { - title - startDate - duration - iterationId - field { - ... on ProjectV2IterationField { - name - } - } - } - ... on ProjectV2ItemFieldUserValue { - users(first: 10) { - nodes { - login - } - } - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldLabelValue { - labels(first: 20) { - nodes { - name - } - } - field { - ... on ProjectV2Field { - name - } - } - } - } - } + ${PROJECT_INFO_FRAGMENT} + ${FIELD_VALUES_FRAGMENT} } } } @@ -383,84 +203,8 @@ query GetItemsProjectDataBatch($nodeIds: [ID!]!) { projectItems(first: 10) { nodes { id - project { - id - title - number - url - } - fieldValues(first: 30) { - nodes { - ... on ProjectV2ItemFieldTextValue { - text - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldNumberValue { - number - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldDateValue { - date - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldSingleSelectValue { - name - optionId - field { - ... on ProjectV2SingleSelectField { - name - } - } - } - ... on ProjectV2ItemFieldIterationValue { - title - startDate - duration - iterationId - field { - ... on ProjectV2IterationField { - name - } - } - } - ... on ProjectV2ItemFieldUserValue { - users(first: 10) { - nodes { - login - } - } - field { - ... on ProjectV2Field { - name - } - } - } - ... on ProjectV2ItemFieldLabelValue { - labels(first: 20) { - nodes { - name - } - } - field { - ... on ProjectV2Field { - name - } - } - } - } - } + ${PROJECT_INFO_FRAGMENT} + ${FIELD_VALUES_FRAGMENT} } } } @@ -621,12 +365,60 @@ query GetProjectItems($projectId: ID!, $first: Int!, $after: String) { ... on Issue { number title + state url + body + createdAt + updatedAt + closedAt + author { + login + } + assignees(first: 10) { + nodes { + login + } + } + labels(first: 10) { + nodes { + name + color + } + } + milestone { + title + } } ... on PullRequest { number title + state url + body + createdAt + updatedAt + closedAt + mergedAt + merged + author { + login + } + assignees(first: 10) { + nodes { + login + } + } + labels(first: 10) { + nodes { + name + color + } + } + milestone { + title + } + baseRefName + headRefName } } fieldValues(first: 20) { diff --git a/src/issue-file-manager.ts b/src/issue-file-manager.ts index ff7f747..ccfe812 100644 --- a/src/issue-file-manager.ts +++ b/src/issue-file-manager.ts @@ -71,19 +71,17 @@ export class IssueFileManager { } } - // Get enabled project IDs from global settings - const enabledProjectIds = this.settings.trackedProjects - .filter(p => p.enabled) - .map(p => p.id); + // Get tracked project IDs from global settings + const trackedProjectIds = this.settings.trackedProjects.map(p => p.id); // Create or update issue files (openIssues contains filtered issues from main.ts) for (const issue of openIssues) { let projectData = issue.node_id ? projectDataMap.get(issue.node_id) : undefined; - // Filter by enabled projects from global settings - if (projectData && enabledProjectIds.length > 0) { + // Filter by tracked projects from global settings + if (projectData && trackedProjectIds.length > 0) { projectData = projectData.filter(p => - enabledProjectIds.includes(p.projectId) + trackedProjectIds.includes(p.projectId) ); } diff --git a/src/kanban-view.ts b/src/kanban-view.ts index 2e1f9a9..bc5c7be 100644 --- a/src/kanban-view.ts +++ b/src/kanban-view.ts @@ -1,5 +1,5 @@ -import { ItemView, WorkspaceLeaf, TFile, Notice } from "obsidian"; -import { GitHubTrackerSettings, ProjectData } from "./types"; +import { ItemView, WorkspaceLeaf, TFile, Notice, setIcon } from "obsidian"; +import { GitHubTrackerSettings, ProjectData, TrackedProject } from "./types"; export const KANBAN_VIEW_TYPE = "github-kanban-view"; @@ -7,6 +7,8 @@ export class GitHubKanbanView extends ItemView { private settings: GitHubTrackerSettings; private refreshInterval: NodeJS.Timeout | null = null; private projectDataCache: Map = new Map(); + private activeProjectId: string | null = null; + private loadedProjects: Set = new Set(); // Track which projects have been loaded private normalizeUrl(url?: string): string | null { if (!url) return null; @@ -50,11 +52,11 @@ export class GitHubKanbanView extends ItemView { } getIcon(): string { - return "kanban"; + return "square-kanban"; } async onOpen(): Promise { - await this.loadProjectDataCache(); + // Don't load all projects at startup - just render the UI await this.render(); this.startAutoRefresh(); } @@ -81,47 +83,173 @@ export class GitHubKanbanView extends ItemView { const container = this.containerEl.children[1]; container.empty(); - // Reload project data cache - await this.loadProjectDataCache(); + // Get tracked projects + const trackedProjects = this.settings.trackedProjects || []; - // Header - const header = container.createDiv("github-kanban-header"); - header.createEl("h2", { text: "GitHub Projects Kanban" }); - - const refreshButton = header.createEl("button", { - text: "🔄 Refresh", - cls: "github-kanban-refresh-btn" - }); - refreshButton.onclick = () => this.render(); - - // Use all cached projects (including those not explicitly tracked) - const cachedProjectIds = Array.from(this.projectDataCache.keys()); - if (cachedProjectIds.length === 0) { + if (trackedProjects.length === 0) { container.createEl("p", { - text: "No project items found. Try refreshing or enable projects in settings.", + text: "No projects tracked. Go to Settings → GitHub Projects to add projects.", cls: "github-kanban-empty" }); return; } - // Build project info objects from cache (use first item to get projectTitle/number/url) - const projectsToRender: any[] = []; - for (const projectId of cachedProjectIds) { - const projectItems = this.projectDataCache.get(projectId) || []; - if (projectItems.length === 0) continue; - const sample = projectItems[0]; - projectsToRender.push({ id: projectId, title: sample.projectTitle || projectId, number: sample.projectNumber || 0, url: sample.projectUrl || '' }); + // Tab bar + const tabBar = container.createDiv("github-kanban-tabs github-kanban-tabs-inline"); + + // Content container for the active project + const contentContainer = container.createDiv("github-kanban-content"); + + // Set first project as active if none selected + if (!this.activeProjectId || !trackedProjects.find(p => p.id === this.activeProjectId)) { + this.activeProjectId = trackedProjects[0].id; + } + + // Create tabs + for (const project of trackedProjects) { + const tab = tabBar.createEl("button", { + text: `${project.title}`, + cls: `github-kanban-tab github-kanban-tab-styled${project.id === this.activeProjectId ? " active" : ""}` + }); + + // Show loading indicator if not yet loaded + if (!this.loadedProjects.has(project.id)) { + tab.textContent += " ○"; + } + + tab.onclick = async () => { + this.activeProjectId = project.id; + // Update tab styles + tabBar.querySelectorAll(".github-kanban-tab").forEach((t: HTMLElement) => { + t.removeClass("active"); + }); + tab.addClass("active"); + + // Load and render the project + await this.renderActiveProject(contentContainer); + + // Update tab to remove loading indicator + if (this.loadedProjects.has(project.id)) { + tab.textContent = `${project.title}`; + } + }; + } + + // Spacer to push refresh button to the right + const spacer = tabBar.createDiv("github-kanban-spacer"); + + // Refresh button (at the right of tab bar) + const refreshButton = tabBar.createEl("button", { + cls: "github-kanban-refresh-btn github-kanban-refresh-styled" + }); + setIcon(refreshButton, "refresh-cw"); + refreshButton.onclick = async () => { + // Clear cache for active project and reload + if (this.activeProjectId) { + this.projectDataCache.delete(this.activeProjectId); + this.loadedProjects.delete(this.activeProjectId); + } + await this.renderActiveProject(contentContainer); + }; + + // Render the active project + await this.renderActiveProject(contentContainer); + } + + private async renderActiveProject(container: Element): Promise { + container.empty(); + + if (!this.activeProjectId) { + container.createEl("p", { text: "Select a project tab to view its board." }); + return; + } + + const project = this.settings.trackedProjects?.find(p => p.id === this.activeProjectId); + if (!project) { + container.createEl("p", { text: "Project not found." }); + return; } - // Create Kanban board for each discovered project - for (const project of projectsToRender) { - await this.renderProjectBoard(container, project); + // Show loading state if not cached + if (!this.loadedProjects.has(this.activeProjectId)) { + const loadingEl = container.createDiv("github-kanban-loading github-kanban-loading-styled"); + loadingEl.createEl("p", { text: `Loading ${project.title}...` }); + + // Load the project data + await this.loadSingleProject(this.activeProjectId, project); + this.loadedProjects.add(this.activeProjectId); + + container.empty(); + } + + // Render the project board + await this.renderProjectBoard(container, { + id: project.id, + title: project.title, + number: project.number, + url: project.url + }); + } + + private async loadSingleProject(projectId: string, project: TrackedProject): Promise { + try { + const projectItems = await this.gitHubClient.fetchProjectItems(projectId); + const itemsArray: any[] = []; + + for (const item of projectItems) { + if (!item.content) continue; + const contentUrl: string | undefined = item.content.url; + const normalizedUrl = this.normalizeUrl(contentUrl) as string | null; + + // Parse custom fields + const customFields: any = {}; + for (const fieldValue of item.fieldValues?.nodes || []) { + if (!fieldValue.field?.name) continue; + const fieldName = fieldValue.field.name; + if (fieldValue.text !== undefined) { + customFields[fieldName] = { fieldName, type: 'text', value: fieldValue.text }; + } else if (fieldValue.name !== undefined) { + customFields[fieldName] = { fieldName, type: 'single_select', value: fieldValue.name }; + } else if (fieldValue.date !== undefined) { + customFields[fieldName] = { fieldName, type: 'date', value: fieldValue.date }; + } else if (fieldValue.users?.nodes) { + customFields[fieldName] = { fieldName, type: 'user', value: fieldValue.users.nodes.map((u: any) => u.login).join(', '), users: fieldValue.users.nodes.map((u: any) => u.login) }; + } + } + + // Extract labels from content + const labels = item.content.labels?.nodes?.map((l: any) => ({ + name: l.name, + color: l.color + })) || []; + + const projectData = { + projectId: projectId, + projectTitle: project.title, + projectNumber: project.number, + projectUrl: project.url, + itemId: item.id, + number: item.content.number, + title: item.content.title, + body: item.content.body || '', + author: item.content.author?.login || 'unknown', + labels: labels, + url: contentUrl, + normalizedUrl, + customFields, + status: customFields?.Status?.value ?? null + }; + itemsArray.push(projectData); + } + + this.projectDataCache.set(projectId, itemsArray); + } catch (error) { + console.error(`Error loading project data for ${projectId}:`, error); } } private async renderProjectBoard(container: Element, project: any): Promise { const projectContainer = container.createDiv("github-kanban-project"); - projectContainer.createEl("h3", { text: `${project.title} (#${project.number})` }); const boardContainer = projectContainer.createDiv("github-kanban-board"); @@ -131,13 +259,8 @@ export class GitHubKanbanView extends ItemView { // Group by status const statusColumns = this.groupItemsByStatus(items); - // Create columns dynamically based on found statuses - const allStatuses = Array.from(statusColumns.keys()); - // Sort statuses, put "No Status" at the end - const sortedStatuses = allStatuses - .filter(status => status !== "No Status") - .sort() - .concat(allStatuses.includes("No Status") ? ["No Status"] : []); + // Get sorted statuses based on settings + const sortedStatuses = this.getSortedStatuses(project.id, statusColumns); for (const status of sortedStatuses) { const columnItems = statusColumns.get(status) || []; @@ -145,140 +268,157 @@ export class GitHubKanbanView extends ItemView { } } - private async loadProjectDataCache(): Promise { - this.projectDataCache.clear(); + private getSortedStatuses(projectId: string, statusColumns: Map): string[] { + const statusesWithItems = Array.from(statusColumns.keys()); - // Build a deduplicated list of projects to fetch: include all tracked projects and all projects found in configured repositories - const projectsToFetch = new Map(); + // Find the tracked project settings + const trackedProject = this.settings.trackedProjects?.find(p => p.id === projectId); - // Include tracked projects (if any) - for (const p of (this.settings.trackedProjects || [])) { - projectsToFetch.set(p.id, { id: p.id, title: p.title, number: p.number, url: p.url }); + if (!trackedProject) { + // Fallback: alphabetical with "No Status" at end + return this.defaultStatusSort(statusesWithItems); } - // Also fetch projects directly from each configured repository (so we show ALL projects) - for (const repoCfg of (this.settings.repositories || [])) { - const [owner, repoName] = (repoCfg.repository || '').split('/'); - if (!owner || !repoName) continue; - try { - const repoProjects = await this.gitHubClient.fetchProjectsForRepository(owner, repoName); - for (const rp of repoProjects) { - if (!projectsToFetch.has(rp.id)) { - projectsToFetch.set(rp.id, { id: rp.id, title: rp.title, number: rp.number, url: rp.url }); - } - } - } catch (err) { - console.error(`Error fetching projects for ${repoCfg.repository}:`, err); - } + // Determine the order to use (includes ALL statuses, even empty ones) + let statusOrder: string[] = []; + + if (trackedProject.useCustomStatusOrder && trackedProject.customStatusOrder?.length) { + // Use custom order + statusOrder = trackedProject.customStatusOrder; + } else if (trackedProject.statusOptions?.length) { + // Use GitHub API order + statusOrder = trackedProject.statusOptions.map(opt => opt.name); } - // Now fetch items for each discovered project - for (const [projectId, projInfo] of projectsToFetch.entries()) { - try { - const projectItems = await this.gitHubClient.fetchProjectItems(projectId); - const itemsArray: any[] = []; - for (const item of projectItems) { - if (!item.content) continue; - const contentUrl: string | undefined = item.content.url; - const normalizedUrl = this.normalizeUrl(contentUrl) as string | null; - - // Parse custom fields - const customFields: any = {}; - for (const fieldValue of item.fieldValues?.nodes || []) { - if (!fieldValue.field?.name) continue; - const fieldName = fieldValue.field.name; - if (fieldValue.text !== undefined) { - customFields[fieldName] = { fieldName, type: 'text', value: fieldValue.text }; - } else if (fieldValue.name !== undefined) { - customFields[fieldName] = { fieldName, type: 'single_select', value: fieldValue.name }; - } else if (fieldValue.date !== undefined) { - customFields[fieldName] = { fieldName, type: 'date', value: fieldValue.date }; - } else if (fieldValue.users?.nodes) { - customFields[fieldName] = { fieldName, type: 'user', value: fieldValue.users.nodes.map((u: any) => u.login).join(', '), users: fieldValue.users.nodes.map((u: any) => u.login) }; - } - } + if (statusOrder.length === 0) { + // No order defined, use default + return this.defaultStatusSort(statusesWithItems); + } - const projectData = { - projectId: projectId, - projectTitle: projInfo.title, - projectNumber: projInfo.number, - projectUrl: projInfo.url, - itemId: item.id, - number: item.content.number, - title: item.content.title, - url: contentUrl, - normalizedUrl, - customFields, - status: customFields?.Status?.value ?? null - }; - itemsArray.push(projectData); - } + // Check settings + const showEmptyColumns = trackedProject.showEmptyColumns ?? true; + const hiddenStatuses = new Set(trackedProject.hiddenStatuses || []); - this.projectDataCache.set(projectId, itemsArray); - console.log(`Project ${projectId} cached ${itemsArray.length} items, sample:`, itemsArray.slice(0, 6).map(i => ({ number: i.number, url: i.url, normalizedUrl: i.normalizedUrl, status: i.status }))); - } catch (error) { - console.error(`Error loading project data for ${projectId}:`, error); + const orderedStatuses: string[] = []; + const remainingStatuses = new Set(statusesWithItems); + + // Add statuses from the defined order + for (const status of statusOrder) { + // Skip hidden statuses + if (hiddenStatuses.has(status)) { + remainingStatuses.delete(status); + continue; + } + // Only add if it has items OR showEmptyColumns is true + if (showEmptyColumns || remainingStatuses.has(status)) { + orderedStatuses.push(status); } + remainingStatuses.delete(status); } - console.log(`Loaded project data cache for ${Array.from(this.projectDataCache.keys()).length} projects`); + // Add any remaining statuses that have items but aren't in the order (except "No Status" and hidden ones) + const remaining = Array.from(remainingStatuses) + .filter(s => s !== "No Status" && !hiddenStatuses.has(s)) + .sort(); + orderedStatuses.push(...remaining); + + // Always put "No Status" at the end if it has items and is not hidden + if (remainingStatuses.has("No Status") && !hiddenStatuses.has("No Status")) { + orderedStatuses.push("No Status"); + } + + return orderedStatuses; + } + + private defaultStatusSort(statuses: string[]): string[] { + return statuses + .filter(status => status !== "No Status") + .sort() + .concat(statuses.includes("No Status") ? ["No Status"] : []); } private async getProjectItems(project: any): Promise { const items: any[] = []; - // Get all markdown files + const trackedProject = this.settings.trackedProjects?.find(p => p.id === project.id); + + const processFolder = (folder: string | undefined): string | undefined => { + if (!folder) return undefined; + const sanitize = (str: string) => str.replace(/[<>:"|?*\\]/g, "-").replace(/\.\./g, ".").trim(); + return folder + .replace(/\{project\}/g, sanitize(project.title || "")) + .replace(/\{owner\}/g, sanitize(project.owner || "")) + .replace(/\{project_number\}/g, (project.number || "").toString()); + }; + + const issueFolder = processFolder( + trackedProject?.useCustomIssueFolder + ? trackedProject?.customIssueFolder + : trackedProject?.issueFolder + ); + const prFolder = processFolder( + trackedProject?.useCustomPullRequestFolder + ? trackedProject?.customPullRequestFolder + : trackedProject?.pullRequestFolder + ); + const files = this.app.vault.getMarkdownFiles(); const matchedNumbers = new Set(); const matchedUrls = new Set(); + const cachedItemsForProject = this.projectDataCache.get(project.id) || []; + + const isFileInProjectFolder = (filePath: string): boolean => { + if (issueFolder && filePath.startsWith(issueFolder + "/")) return true; + if (prFolder && filePath.startsWith(prFolder + "/")) return true; + return false; + }; + + const hasAnyProjectFolder = !!(issueFolder || prFolder); for (const file of files) { try { - // Read file content - const content = await this.app.vault.read(file); + if (hasAnyProjectFolder && !isFileInProjectFolder(file.path)) { + continue; + } - // Parse frontmatter to get project data + const content = await this.app.vault.read(file); const frontmatter = this.parseFrontmatter(content); if (!frontmatter) continue; - // Check if this is an issue or PR by looking at frontmatter const isIssue = frontmatter.number && frontmatter.title && frontmatter.state; if (!isIssue) continue; - // Check project cache entries for this project - const cachedItemsForProject = this.projectDataCache.get(project.id) || []; + const isInProjectFolder = hasAnyProjectFolder; + const fileMatchesProject = frontmatter.project === project.title; const itemUrl = frontmatter.url; const normalizedItemUrl = this.normalizeUrl(itemUrl); - // Debug log - if (itemUrl) console.log(`Checking item ${frontmatter.title} (${itemUrl}) in project ${project.id} (normalized: ${normalizedItemUrl})`); - -// Try to find a match by number first (robust), then by normalized URL - let fullProjectData: any = null; - const fmNum = this.parseNumber(frontmatter.number); - console.log(`Parsed frontmatter number for '${frontmatter.title}':`, frontmatter.number, '->', fmNum); - if (fmNum !== null) { - fullProjectData = cachedItemsForProject.find((ci: any) => Number(ci.number) === fmNum) || null; + let fullProjectData: any = null; + const fmNum = this.parseNumber(frontmatter.number); + if (fmNum !== null) { + fullProjectData = cachedItemsForProject.find((ci: any) => Number(ci.number) === fmNum) || null; if (fullProjectData) matchedNumbers.add(fmNum); } if (!fullProjectData && normalizedItemUrl) { fullProjectData = cachedItemsForProject.find((ci: any) => ci.normalizedUrl === normalizedItemUrl) || null; if (fullProjectData && fullProjectData.normalizedUrl) matchedUrls.add(fullProjectData.normalizedUrl); } - if (fullProjectData) { - // Debug: print the matched project data for this item - console.log(`Matched project data for ${frontmatter.title}:`, fullProjectData); - // Extract status from top-level status or customFields.Status.value - let projectStatus = fullProjectData?.status || fullProjectData?.customFields?.Status?.value || "No Status"; - // Extract item info with project details + if (isInProjectFolder || fullProjectData || fileMatchesProject) { + let projectStatus = frontmatter.project_status + || fullProjectData?.status + || fullProjectData?.customFields?.Status?.value + || "No Status"; + const item = { ...frontmatter, file: file, title: frontmatter.title, number: frontmatter.number, state: frontmatter.state, - labels: frontmatter.labels || [], + labels: fullProjectData?.labels || frontmatter.labels || [], + body: fullProjectData?.body || '', + author: fullProjectData?.author || frontmatter.opened_by || frontmatter.author || 'unknown', pull_request: frontmatter.type === "pr", projectStatus: projectStatus, projectTitle: project.title, @@ -294,31 +434,6 @@ export class GitHubKanbanView extends ItemView { } - // Add remote-only project items (those that are in the project but have no local file) - const cachedItemsForProject = this.projectDataCache.get(project.id) || []; - for (const ci of cachedItemsForProject) { - const ciNum = Number(ci.number); - const ciNorm = ci.normalizedUrl; - if ((ciNum && matchedNumbers.has(ciNum)) || (ciNorm && matchedUrls.has(ciNorm))) continue; - - // Synthesize an item for display - const synthetic: any = { - title: ci.title || `#${ci.number}`, - number: ci.number, - state: ci.status || 'unknown', - labels: [], - pull_request: false, - projectStatus: ci.status || 'No Status', - projectTitle: project.title, - projectNumber: project.number, - projectUrl: project.url, - fullProjectData: ci, - remoteOnly: true, - url: ci.url - }; - items.push(synthetic); - } - return items; } @@ -346,6 +461,11 @@ export class GitHubKanbanView extends ItemView { result[key] = value; } } else { + // Strip surrounding quotes from string values + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } result[key] = value; } } @@ -386,75 +506,106 @@ export class GitHubKanbanView extends ItemView { private renderKanbanItem(container: Element, item: any): void { const itemEl = container.createDiv("github-kanban-item"); - // Title - const titleEl = itemEl.createEl("div", "github-kanban-item-title"); - titleEl.setText(item.title || "Untitled"); + // Header: Number and type + const headerEl = itemEl.createEl("div", { cls: "github-kanban-item-header" }); - // Number and type - const metaEl = itemEl.createEl("div", "github-kanban-item-meta"); const type = item.pull_request ? "PR" : "Issue"; const number = item.number; - metaEl.setText(`#${number} (${type})`); + headerEl.createEl("span", { + text: `#${number} · ${type}`, + cls: "github-kanban-item-type" + }); + + // Title + const titleEl = itemEl.createEl("div", { cls: "github-kanban-item-title" }); + titleEl.setText(item.title || "Untitled"); // Labels if (item.labels && item.labels.length > 0) { - const labelsEl = itemEl.createEl("div", "github-kanban-item-labels"); - for (const label of item.labels.slice(0, 3)) { // Show max 3 labels - const labelEl = labelsEl.createEl("span", "github-kanban-label"); + const labelsEl = itemEl.createEl("div", { cls: "github-kanban-item-labels" }); + + for (const label of item.labels.slice(0, 5)) { + const labelEl = labelsEl.createEl("span", { cls: "github-kanban-label" }); labelEl.setText(label.name); - labelEl.style.backgroundColor = `#${label.color}`; + + // Set background color and calculate text color (dynamic, must stay inline) + const bgColor = label.color || 'cccccc'; + labelEl.style.backgroundColor = `#${bgColor}`; + const r = parseInt(bgColor.slice(0, 2), 16); + const g = parseInt(bgColor.slice(2, 4), 16); + const b = parseInt(bgColor.slice(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + labelEl.style.color = luminance > 0.5 ? '#000000' : '#ffffff'; + } + + if (item.labels.length > 5) { + labelsEl.createEl("span", { + text: `+${item.labels.length - 5}`, + cls: "github-kanban-label-more" + }); } } - // Show project status and custom fields - if (item.projectStatus) { - const statusBadge = itemEl.createEl('div', { cls: 'github-kanban-item-meta' }); - statusBadge.setText(`Project status: ${item.projectStatus}`); + // Creator + if (item.author) { + const creatorEl = itemEl.createEl("div", { cls: "github-kanban-item-creator" }); + const userIcon = creatorEl.createEl("span", { cls: "github-kanban-user-icon" }); + setIcon(userIcon, "user"); + creatorEl.createEl("span", { text: item.author }); } - if (item.fullProjectData?.customFields) { - const cfEl = itemEl.createDiv('github-kanban-item-labels'); - for (const [key, val] of Object.entries(item.fullProjectData.customFields)) { - const entry = cfEl.createEl('div', { cls: 'github-kanban-item-meta' }); - let vdisp = ''; - if (val && typeof val === 'object') { - const fieldObj = val as any; - vdisp = fieldObj.value ?? ''; - } - entry.setText(`${key}: ${vdisp}`); + // Description preview (first 150 chars) + if (item.body && item.body.trim()) { + const descEl = itemEl.createEl("div", { cls: "github-kanban-item-description" }); + + // Clean up the body text (remove markdown syntax, extra whitespace) + let bodyPreview = item.body + .replace(/```[\s\S]*?```/g, '') // Remove code blocks + .replace(/`[^`]*`/g, '') // Remove inline code + .replace(/!\[.*?\]\(.*?\)/g, '') // Remove images + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // Convert links to text + .replace(/#{1,6}\s*/g, '') // Remove headings + .replace(/[*_~]+/g, '') // Remove bold/italic/strikethrough + .replace(/\n+/g, ' ') // Convert newlines to spaces + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); + + if (bodyPreview.length > 150) { + bodyPreview = bodyPreview.substring(0, 150) + '...'; } - } - // Show raw project item data (collapsible) - if (item.fullProjectData) { - const details = itemEl.createEl('details', { cls: 'github-kanban-item-meta' }); - details.createEl('summary', { text: 'Show raw project data' }); - const pre = details.createEl('pre'); - pre.setText(JSON.stringify(item.fullProjectData, null, 2)); + descEl.setText(bodyPreview); } - // Make clickable to open the file + // Make clickable to open the file or GitHub URL itemEl.onclick = () => this.openItemFile(item); } private async openItemFile(item: any): Promise { - // Try to find the file by matching frontmatter number and repo const files = this.app.vault.getMarkdownFiles(); - for (const file of files) { - try { - const content = await this.app.vault.read(file); - const fm = this.parseFrontmatter(content); - if (!fm) continue; - // Match by number and (optionally) repo derived from URL - if (fm.number && item.number && fm.number.toString() === item.number.toString()) { - await this.app.workspace.getLeaf().openFile(file); - return; + + // First try: match by URL (most accurate) + if (item.url) { + for (const file of files) { + try { + const content = await this.app.vault.read(file); + const fm = this.parseFrontmatter(content); + if (!fm) continue; + if (fm.url && fm.url === item.url) { + await this.app.workspace.getLeaf().openFile(file); + return; + } + } catch (e) { + // ignore } - } catch (e) { - // ignore } } - new Notice(`File for #${item.number} not found in vault`); + // Fallback: open GitHub URL if available + if (item.url) { + window.open(item.url, '_blank'); + } else { + new Notice(`File for #${item.number} not found in vault`); + } } } diff --git a/src/main.ts b/src/main.ts index 611fc24..dce2db9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ export default class GitHubTrackerPlugin extends Plugin { this.noticeManager.info("Syncing issues and pull requests"); await this.fetchIssues(); await this.fetchPullRequests(); + await this.syncProjects(); await this.fileManager?.cleanupEmptyFolders(); this.noticeManager.success("Synced issues and pull requests"); @@ -43,6 +44,55 @@ export default class GitHubTrackerPlugin extends Plugin { } } + /** + * Sync items from tracked GitHub Projects + */ + private async syncProjects() { + if (!this.settings.enableProjectTracking) { + return; + } + + if (!this.gitHubClient || !this.fileManager) { + return; + } + + const hasAnyFolderConfigured = (p: any) => + p.issueFolder || p.pullRequestFolder || p.customIssueFolder || p.customPullRequestFolder; + + const enabledProjects = this.settings.trackedProjects.filter( + (p) => p.enabled && hasAnyFolderConfigured(p) + ); + + if (enabledProjects.length === 0) { + this.noticeManager.debug("No projects with folders configured"); + return; + } + + for (const project of enabledProjects) { + try { + this.noticeManager.debug(`Syncing project: ${project.title}`); + + const items = await this.gitHubClient.fetchProjectItems(project.id); + + if (items.length === 0) { + this.noticeManager.debug(`No items found in project ${project.title}`); + continue; + } + + await this.fileManager.createProjectItemFiles(project, items); + + this.noticeManager.debug( + `Processed ${items.length} items for project ${project.title}` + ); + } catch (error: unknown) { + this.noticeManager.error( + `Error syncing project ${project.title}`, + error + ); + } + } + } + async syncSingleRepository(repositoryName: string) { if (this.isSyncing) { this.noticeManager.warning("Already syncing..."); @@ -178,6 +228,67 @@ export default class GitHubTrackerPlugin extends Plugin { } } + /** + * Sync a single project by ID + */ + async syncSingleProject(projectId: string) { + if (this.isSyncing) { + this.noticeManager.warning("Already syncing..."); + return; + } + + if (!this.gitHubClient || !this.fileManager) { + this.noticeManager.error( + "GitHub client or file manager not initialized", + ); + return; + } + + const project = this.settings.trackedProjects.find( + (p) => p.id === projectId, + ); + + if (!project) { + this.noticeManager.error( + `Project ${projectId} not found in settings`, + ); + return; + } + + const hasAnyFolder = project.issueFolder || project.pullRequestFolder || + project.customIssueFolder || project.customPullRequestFolder; + + if (!hasAnyFolder) { + this.noticeManager.warning( + `No folder configured for project ${project.title}. Please configure a folder in project settings.`, + ); + return; + } + + this.isSyncing = true; + try { + this.noticeManager.info(`Syncing project: ${project.title}`); + + const items = await this.gitHubClient.fetchProjectItems(project.id); + + if (items.length === 0) { + this.noticeManager.info(`No items found in project ${project.title}`); + } else { + await this.fileManager.createProjectItemFiles(project, items); + this.noticeManager.success( + `Successfully synced ${items.length} items from ${project.title}`, + ); + } + } catch (error: unknown) { + this.noticeManager.error( + `Error syncing project ${project.title}`, + error, + ); + } finally { + this.isSyncing = false; + } + } + async onload() { await this.loadSettings(); @@ -310,6 +421,7 @@ export default class GitHubTrackerPlugin extends Plugin { if (!merged.pullRequestFolder) merged.pullRequestFolder = DEFAULT_REPOSITORY_TRACKING.pullRequestFolder; return merged; }); + } async saveSettings() { diff --git a/src/pr-file-manager.ts b/src/pr-file-manager.ts index ae2fe89..4564579 100644 --- a/src/pr-file-manager.ts +++ b/src/pr-file-manager.ts @@ -73,19 +73,17 @@ export class PullRequestFileManager { } } - // Get enabled project IDs from global settings - const enabledProjectIds = this.settings.trackedProjects - .filter(p => p.enabled) - .map(p => p.id); + // Get tracked project IDs from global settings + const trackedProjectIds = this.settings.trackedProjects.map(p => p.id); // Create or update pull request files (openPullRequests contains filtered PRs from main.ts) for (const pr of openPullRequests) { let projectData = pr.node_id ? projectDataMap.get(pr.node_id) : undefined; - // Filter by enabled projects from global settings - if (projectData && enabledProjectIds.length > 0) { + // Filter by tracked projects from global settings + if (projectData && trackedProjectIds.length > 0) { projectData = projectData.filter(p => - enabledProjectIds.includes(p.projectId) + trackedProjectIds.includes(p.projectId) ); } diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 2df290c..7f7719a 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -10,7 +10,7 @@ import { AbstractInputSuggest, TAbstractFile, } from "obsidian"; -import { RepositoryTracking, DEFAULT_REPOSITORY_TRACKING } from "./types"; +import { RepositoryTracking, DEFAULT_REPOSITORY_TRACKING, TrackedProject } from "./types"; import GitHubTrackerPlugin from "./main"; import { FolderSuggest } from "./settings/folder-suggest"; import { FileSuggest } from "./settings/file-suggest"; @@ -18,12 +18,16 @@ import { RepositoryRenderer } from "./settings/repository-renderer"; import { UIHelpers } from "./settings/ui-helpers"; import { RepositoryListManager } from "./settings/repository-list-manager"; import { ModalManager } from "./settings/modal-manager"; +import { ProjectListManager } from "./settings/project-list-manager"; +import { ProjectRenderer } from "./settings/project-renderer"; export class GitHubTrackerSettingTab extends PluginSettingTab { private selectedRepositories: Set = new Set(); private repositoryRenderer: RepositoryRenderer; private repositoryListManager: RepositoryListManager; private modalManager: ModalManager; + private projectListManager: ProjectListManager; + private projectRenderer: ProjectRenderer; constructor( app: App, @@ -31,16 +35,18 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { ) { super(app, plugin); - // Initialize managers - this.modalManager = new ModalManager(this.app, this.plugin); - this.repositoryRenderer = new RepositoryRenderer( - this.app, - this.plugin, - (repoName, repo, filterType, textArea) => this.modalManager.fetchAndShowRepositoryLabels(repoName, repo, filterType, textArea), - (repoName, repo, filterType, textArea) => this.modalManager.fetchAndShowRepositoryCollaborators(repoName, repo, filterType, textArea) - ); - this.repositoryListManager = new RepositoryListManager(this.app, this.plugin); -} async display(): Promise { + // Initialize managers + this.modalManager = new ModalManager(this.app, this.plugin); + this.repositoryRenderer = new RepositoryRenderer( + this.app, + this.plugin, + (repoName, repo, filterType, textArea) => this.modalManager.fetchAndShowRepositoryLabels(repoName, repo, filterType, textArea), + (repoName, repo, filterType, textArea) => this.modalManager.fetchAndShowRepositoryCollaborators(repoName, repo, filterType, textArea) + ); + this.repositoryListManager = new RepositoryListManager(this.app, this.plugin); + this.projectListManager = new ProjectListManager(this.app, this.plugin); + this.projectRenderer = new ProjectRenderer(this.app, this.plugin); + } async display(): Promise { const { containerEl } = this; containerEl.empty(); @@ -340,19 +346,13 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { }), ); - const escapingInfo = advancedContainer.createDiv(); - escapingInfo.addClass("github-issues-info-text"); - escapingInfo.style.marginTop = "8px"; + const escapingInfo = advancedContainer.createDiv("github-issues-info-text github-issues-escaping-info"); const escapingDetails = escapingInfo.createEl("details"); - const escapingSummary = escapingDetails.createEl("summary"); + const escapingSummary = escapingDetails.createEl("summary", { cls: "github-issues-escaping-summary" }); escapingSummary.textContent = "Escaping mode details"; - escapingSummary.style.cursor = "pointer"; - escapingSummary.style.fontWeight = "500"; - const escapingContent = escapingDetails.createDiv(); - escapingContent.style.marginTop = "8px"; - escapingContent.style.paddingLeft = "12px"; + const escapingContent = escapingDetails.createDiv("github-issues-escaping-content"); const warningP = escapingContent.createEl("p"); warningP.textContent = "⚠️ CAUTION: Disabling escaping may allow malicious scripts to execute"; @@ -571,14 +571,13 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { ); // GitHub Projects Section - const projectsContainer = containerEl.createDiv("github-issues-settings-group"); - projectsContainer.style.marginTop = "30px"; + const projectsContainer = containerEl.createDiv("github-issues-settings-group github-issues-section-margin"); new Setting(projectsContainer).setName("GitHub Projects").setHeading(); projectsContainer .createEl("p", { - text: "Track GitHub Projects (v2) data and make project fields available as template variables for issues and pull requests.", + text: "Track GitHub Projects (v2) and create notes for project items. Project fields are available as template variables.", }) .addClass("setting-item-description"); @@ -586,133 +585,122 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { "github-issues-settings-group", ); - new Setting(projectsContainer) - .setName("Enable project tracking") - .setDesc( - "When enabled, project data like status, priority, and custom fields become available as template variables.", - ) - .addToggle((toggle) => - toggle.setValue(this.plugin.settings.enableProjectTracking).onChange(async (value) => { - this.plugin.settings.enableProjectTracking = value; - projectSettingsContainer.classList.toggle( - "github-issues-settings-hidden", - !value, - ); - await this.plugin.saveSettings(); - }), - ); + // Tabs for Projects (like Repositories) + const projectTabsContainer = projectSettingsContainer.createDiv( + "github-issues-repos-tabs-container", + ); + + const trackedProjectsTab = projectTabsContainer.createEl("button", { + text: "Tracked Projects", + }); + trackedProjectsTab.addClass("github-issues-tab"); + trackedProjectsTab.addClass("mod-cta"); + + const availableProjectsTab = projectTabsContainer.createEl("button", { + text: "Available Projects", + }); + availableProjectsTab.addClass("github-issues-tab"); + + const trackedProjectsContent = projectSettingsContainer.createDiv( + "github-issues-tab-content", + ); + trackedProjectsContent.addClass("active"); - projectSettingsContainer.classList.toggle( - "github-issues-settings-hidden", - !this.plugin.settings.enableProjectTracking, + const availableProjectsContent = projectSettingsContainer.createDiv( + "github-issues-tab-content", ); - // Project list - const projectListContainer = projectSettingsContainer.createDiv( + // Tracked Projects content + const projectListContainer = trackedProjectsContent.createDiv( "github-issues-project-list", ); - this.renderTrackedProjects(projectListContainer); - - // Load projects button - const loadProjectsContainer = projectSettingsContainer.createDiv(); - loadProjectsContainer.style.display = "flex"; - loadProjectsContainer.style.flexDirection = "column"; - loadProjectsContainer.style.gap = "8px"; + this.projectListManager.renderProjectsList( + projectListContainer, + () => this.display(), + (container, project) => this.projectRenderer.renderProjectSettings(container, project), + async (project) => { + // Delete single project + this.plugin.settings.trackedProjects = this.plugin.settings.trackedProjects.filter( + p => p.id !== project.id + ); + await this.plugin.saveSettings(); + new Notice(`Removed project: ${project.title}`); + this.display(); + }, + async (projects) => { + // Bulk delete projects + const ids = new Set(projects.map(p => p.id)); + this.plugin.settings.trackedProjects = this.plugin.settings.trackedProjects.filter( + p => !ids.has(p.id) + ); + await this.plugin.saveSettings(); + new Notice(`Removed ${projects.length} projects`); + this.display(); + } + ); - const loadProjectsButton = loadProjectsContainer.createEl("button"); - loadProjectsButton.setText("Load Projects"); - loadProjectsButton.title = "Load projects from tracked repositories"; + // Available Projects content + const loadProjectsButtonContainer = availableProjectsContent.createDiv( + "github-issues-load-repos-container", + ); - const loadDirectContainer = loadProjectsContainer.createDiv(); - loadDirectContainer.style.display = "flex"; - loadDirectContainer.style.alignItems = "center"; - loadDirectContainer.style.gap = "8px"; + const projectsLoadDescription = loadProjectsButtonContainer.createEl("p", { + text: "Load your GitHub Projects to add them to tracking.", + cls: "github-issues-load-description", + }); - const directRepoInput = loadDirectContainer.createEl("input"); - directRepoInput.type = "text"; - directRepoInput.placeholder = "owner/repo-name"; - directRepoInput.style.flex = "1"; - directRepoInput.style.padding = "4px 8px"; - directRepoInput.style.border = "1px solid var(--background-modifier-border)"; - directRepoInput.style.borderRadius = "4px"; + const loadProjectsButton = loadProjectsButtonContainer.createEl("button"); + loadProjectsButton.addClass("github-issues-action-button"); + const projectsButtonIcon = loadProjectsButton.createEl("span", { + cls: "github-issues-button-icon", + }); + setIcon(projectsButtonIcon, "download"); + loadProjectsButton.createEl("span", { text: "Load Projects" }); - const loadDirectButton = loadDirectContainer.createEl("button"); - loadDirectButton.setText("Load from Repo"); - loadDirectButton.title = "Load projects directly from a specific repository"; + const projectsResultsContainer = availableProjectsContent.createDiv( + "github-issues-repos-results-container", + ); + projectsResultsContainer.addClass("github-issues-hidden"); loadProjectsButton.onclick = async () => { loadProjectsButton.disabled = true; - loadProjectsButton.setText("Loading..."); + const buttonText = loadProjectsButton.querySelector("span:last-child"); + if (buttonText) { + buttonText.textContent = "Loading..."; + } try { - await this.loadProjectsFromRepositories(); - projectListContainer.empty(); - this.renderTrackedProjects(projectListContainer); + await this.renderAvailableProjects(projectsResultsContainer); + projectsResultsContainer.removeClass("github-issues-hidden"); + loadProjectsButtonContainer.addClass("github-issues-hidden"); } catch (error) { new Notice(`Error loading projects: ${error}`); } finally { loadProjectsButton.disabled = false; - loadProjectsButton.setText("Load Projects"); + if (buttonText) { + buttonText.textContent = "Load Projects"; + } } }; - loadDirectButton.onclick = async () => { - const repoInput = directRepoInput.value.trim(); - if (!repoInput) { - new Notice("Please enter a repository in owner/repo-name format"); - return; - } - - const [owner, repoName] = repoInput.split("/"); - if (!owner || !repoName) { - new Notice("Please enter repository in owner/repo-name format"); - return; - } - - loadDirectButton.disabled = true; - loadDirectButton.setText("Loading..."); - - try { - await this.loadProjectsFromDirectRepository(owner, repoName); - projectListContainer.empty(); - this.renderTrackedProjects(projectListContainer); - } catch (error) { - new Notice(`Error loading projects: ${error}`); - } finally { - loadDirectButton.disabled = false; - loadDirectButton.setText("Load from Repo"); - } + // Tab switching + trackedProjectsTab.onclick = () => { + trackedProjectsTab.addClass("mod-cta"); + availableProjectsTab.removeClass("mod-cta"); + trackedProjectsContent.addClass("active"); + availableProjectsContent.removeClass("active"); }; - // Template variables info - const projectsInfo = projectSettingsContainer.createDiv(); - projectsInfo.addClass("github-issues-info-text"); - projectsInfo.style.marginTop = "8px"; - - const projectsDetails = projectsInfo.createEl("details"); - const projectsSummary = projectsDetails.createEl("summary"); - projectsSummary.textContent = "Available project template variables"; - projectsSummary.style.cursor = "pointer"; - projectsSummary.style.fontWeight = "500"; - - const projectsContent = projectsDetails.createDiv(); - projectsContent.style.marginTop = "8px"; - projectsContent.style.paddingLeft = "12px"; - - projectsContent.createEl("p").textContent = "• {project} - First project title"; - projectsContent.createEl("p").textContent = "• {project_url} - Project URL"; - projectsContent.createEl("p").textContent = "• {project_status} - Status field value (e.g., 'In Progress')"; - projectsContent.createEl("p").textContent = "• {project_priority} - Priority field value"; - projectsContent.createEl("p").textContent = "• {project_iteration} - Current iteration/sprint name"; - projectsContent.createEl("p").textContent = "• {project_iteration_start} - Iteration start date"; - projectsContent.createEl("p").textContent = "• {project_field:FieldName} - Any custom field by name"; - projectsContent.createEl("p").textContent = "• {projects} - All projects (comma-separated)"; - projectsContent.createEl("p").textContent = "• {projects_yaml} - All projects as YAML array"; + availableProjectsTab.onclick = () => { + availableProjectsTab.addClass("mod-cta"); + trackedProjectsTab.removeClass("mod-cta"); + availableProjectsContent.addClass("active"); + trackedProjectsContent.removeClass("active"); + }; // Repositories Section - const repoContainer = containerEl.createDiv("github-issues-settings-group"); - repoContainer.style.marginTop = "30px"; + const repoContainer = containerEl.createDiv("github-issues-settings-group github-issues-section-margin"); new Setting(repoContainer).setName("Repositories").setHeading(); const repoTabsContainer = repoContainer.createDiv( @@ -1219,7 +1207,7 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { checkbox.addClass("github-issues-checkbox"); if (isTracked) { - checkbox.style.visibility = "hidden"; + checkbox.addClass("github-issues-checkbox-hidden"); } const repoIcon = repoInfoContainer.createDiv( @@ -1826,136 +1814,6 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { } } - /** - * Render the list of tracked projects - */ - private renderTrackedProjects(container: HTMLElement): void { - const projects = this.plugin.settings.trackedProjects; - - if (!projects || projects.length === 0) { - const emptyMessage = container.createEl("p", { - text: "No projects loaded. Click 'Load Projects' to fetch available projects from your tracked repositories.", - cls: "github-issues-empty-message", - }); - emptyMessage.style.color = "var(--text-muted)"; - emptyMessage.style.fontStyle = "italic"; - return; - } - - const enabledCount = projects.filter(p => p.enabled).length; - - const headerContainer = container.createDiv("github-issues-project-list-header"); - headerContainer.style.display = "flex"; - headerContainer.style.justifyContent = "space-between"; - headerContainer.style.alignItems = "center"; - headerContainer.style.marginBottom = "8px"; - - const headerText = headerContainer.createEl("span", { - text: `${enabledCount} of ${projects.length} projects enabled`, - }); - headerText.style.fontWeight = "500"; - - const selectAllContainer = headerContainer.createDiv(); - const selectAllBtn = selectAllContainer.createEl("button", { - text: enabledCount === 0 ? "Enable All" : "Disable All", - cls: "github-issues-select-toggle-btn", - }); - selectAllBtn.style.fontSize = "12px"; - selectAllBtn.style.padding = "2px 8px"; - - selectAllBtn.onclick = async () => { - const newState = enabledCount === 0; - for (const project of this.plugin.settings.trackedProjects) { - project.enabled = newState; - } - await this.plugin.saveSettings(); - - // Re-render - container.empty(); - this.renderTrackedProjects(container); - }; - - const listContainer = container.createDiv("github-issues-project-items"); - listContainer.style.maxHeight = "300px"; - listContainer.style.overflowY = "auto"; - listContainer.style.border = "1px solid var(--background-modifier-border)"; - listContainer.style.borderRadius = "4px"; - listContainer.style.padding = "4px"; - - // Group projects by owner - const projectsByOwner: Record = {}; - for (const project of projects) { - if (!projectsByOwner[project.owner]) { - projectsByOwner[project.owner] = []; - } - projectsByOwner[project.owner].push(project); - } - - for (const owner of Object.keys(projectsByOwner).sort()) { - const ownerProjects = projectsByOwner[owner]; - - const ownerHeader = listContainer.createDiv("github-issues-project-owner-header"); - ownerHeader.style.padding = "6px 8px"; - ownerHeader.style.fontWeight = "500"; - ownerHeader.style.backgroundColor = "var(--background-secondary)"; - ownerHeader.style.borderRadius = "4px"; - ownerHeader.style.marginTop = "4px"; - ownerHeader.textContent = owner; - - for (const project of ownerProjects) { - const projectItem = listContainer.createDiv("github-issues-project-item"); - projectItem.style.display = "flex"; - projectItem.style.alignItems = "center"; - projectItem.style.padding = "4px 8px"; - projectItem.style.cursor = "pointer"; - - projectItem.onmouseenter = () => { - projectItem.style.backgroundColor = "var(--background-modifier-hover)"; - }; - projectItem.onmouseleave = () => { - projectItem.style.backgroundColor = ""; - }; - - const checkbox = projectItem.createEl("input", { - type: "checkbox", - }); - checkbox.checked = project.enabled; - checkbox.style.marginRight = "8px"; - - const labelContainer = projectItem.createDiv(); - labelContainer.style.flex = "1"; - - const titleEl = labelContainer.createEl("span", { - text: project.title, - }); - - const numberEl = labelContainer.createEl("span", { - text: ` #${project.number}`, - }); - numberEl.style.color = "var(--text-muted)"; - numberEl.style.fontSize = "12px"; - - const onToggle = async () => { - project.enabled = checkbox.checked; - await this.plugin.saveSettings(); - - // Update header text - const newEnabledCount = this.plugin.settings.trackedProjects.filter(p => p.enabled).length; - headerText.textContent = `${newEnabledCount} of ${projects.length} projects enabled`; - selectAllBtn.textContent = newEnabledCount === 0 ? "Enable All" : "Disable All"; - }; - - checkbox.onchange = onToggle; - projectItem.onclick = (e) => { - if (e.target !== checkbox) { - checkbox.checked = !checkbox.checked; - onToggle(); - } - }; - } - } - } - /** * Load projects from all tracked repositories */ @@ -2009,6 +1867,17 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { for (const [id, project] of allProjects) { const existing = existingProjects.get(id); + + // Fetch status options for each project + let statusOptions = existing?.statusOptions; + if (!statusOptions) { + try { + statusOptions = await this.plugin.gitHubClient!.fetchProjectStatusOptions(project.id); + } catch { + statusOptions = []; + } + } + newTrackedProjects.push({ id: project.id, title: project.title, @@ -2016,6 +1885,9 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { url: project.url, owner: project.owner, enabled: existing?.enabled ?? true, // Default to enabled for new projects + statusOptions: statusOptions, + customStatusOrder: existing?.customStatusOrder, + useCustomStatusOrder: existing?.useCustomStatusOrder ?? false, }); } @@ -2076,4 +1948,424 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { throw new Error(`Failed to load projects from ${owner}/${repoName}: ${error}`); } } + + /** + * Render available projects list (similar to renderAvailableRepositories) + */ + private async renderAvailableProjects( + container: HTMLElement, + ): Promise { + container.empty(); + + if (!this.plugin.gitHubClient) { + container.createEl("p", { text: "GitHub client not initialized" }); + return; + } + + try { + // Fetch all available projects from user and orgs + const fetchedProjects = await this.plugin.gitHubClient.fetchAllAvailableProjects(); + + const projects = fetchedProjects.map(p => ({ + id: p.id, + title: p.title, + number: p.number, + url: p.url, + owner: p.owner || "unknown", + closed: p.closed, + })); + + container.empty(); + + // Actions bar + const actionsBar = container.createDiv("github-issues-actions-bar"); + + const bulkActionsContainer = actionsBar.createDiv( + "github-issues-bulk-actions", + ); + bulkActionsContainer.addClass("github-issues-bulk-actions-container"); + + const selectionControls = bulkActionsContainer.createDiv( + "github-issues-selection-controls", + ); + const selectAllButton = selectionControls.createEl("button"); + const selectAllIcon = selectAllButton.createEl("span", { + cls: "github-issues-button-icon", + }); + setIcon(selectAllIcon, "check"); + selectAllButton.createEl("span", { + cls: "github-issues-button-text", + text: "Select all", + }); + selectAllButton.addClass("github-issues-select-all-button"); + + const selectNoneButton = selectionControls.createEl("button"); + const selectNoneIcon = selectNoneButton.createEl("span", { + cls: "github-issues-button-icon", + }); + setIcon(selectNoneIcon, "x"); + selectNoneButton.createEl("span", { + cls: "github-issues-button-text", + text: "Select none", + }); + selectNoneButton.addClass("github-issues-select-none-button"); + + const addSelectedButton = bulkActionsContainer.createEl("button"); + addSelectedButton.createEl("span", { + cls: "github-issues-button-icon", + text: "+", + }); + const buttonTextContainer = addSelectedButton.createEl("span", { + cls: "github-issues-button-text", + }); + buttonTextContainer.setText("Add Selected ("); + buttonTextContainer.createEl("span", { + cls: "selected-count", + text: "0", + }); + buttonTextContainer.appendText(")"); + addSelectedButton.addClass("github-issues-add-selected-button"); + addSelectedButton.disabled = true; + + // Search container + const searchContainer = actionsBar.createDiv( + "github-issues-search-container", + ); + searchContainer.addClass("github-issues-search-modern"); + + const searchInputWrapper = searchContainer.createDiv( + "github-issues-search-wrapper", + ); + const searchIconContainer = searchInputWrapper.createDiv( + "github-issues-search-icon", + ); + setIcon(searchIconContainer, "search"); + + const searchInput = searchInputWrapper.createEl("input"); + searchInput.type = "text"; + searchInput.placeholder = "Search projects..."; + searchInput.addClass("github-issues-search-input-modern"); + + const clearButton = searchInputWrapper.createDiv( + "github-issues-clear-button github-issues-hidden", + ); + setIcon(clearButton, "x"); + clearButton.addEventListener("click", () => { + searchInput.value = ""; + clearButton.classList.add("github-issues-hidden"); + searchInput.dispatchEvent(new Event("input")); + searchInput.focus(); + }); + + const statsCounter = searchContainer.createDiv( + "github-issues-stats-counter", + ); + statsCounter.setText(`Showing all ${projects.length} projects`); + + // Project list container + const projectListContainer = container.createDiv( + "github-issues-repo-list", + ); + + const noResultsMessage = container.createDiv( + "github-issues-no-results", + ); + const noResultsIcon = noResultsMessage.createDiv( + "github-issues-no-results-icon", + ); + setIcon(noResultsIcon, "minus-circle"); + const noResultsText = noResultsMessage.createDiv( + "github-issues-no-results-text", + ); + noResultsText.setText("No matching projects found"); + noResultsMessage.addClass("github-issues-hidden"); + + // Group projects by owner + const projectsByOwner: Record = {}; + for (const project of projects) { + if (!projectsByOwner[project.owner]) { + projectsByOwner[project.owner] = []; + } + projectsByOwner[project.owner].push(project); + } + + const sortedOwners = Object.keys(projectsByOwner).sort(); + + // Track selected projects + const selectedProjects = new Set(); + + const updateSelectionUI = () => { + const selectedCount = selectedProjects.size; + const selectedCountSpan = addSelectedButton.querySelector( + ".selected-count", + ) as HTMLElement; + if (selectedCountSpan) { + selectedCountSpan.textContent = selectedCount.toString(); + } + addSelectedButton.disabled = selectedCount === 0; + }; + + for (const ownerName of sortedOwners) { + const ownerProjects = projectsByOwner[ownerName]; + const ownerContainer = projectListContainer.createDiv(); + ownerContainer.addClass("github-issues-repo-owner-group"); + ownerContainer.setAttribute("data-owner", ownerName.toLowerCase()); + + const ownerHeader = ownerContainer.createDiv( + "github-issues-repo-owner-header", + ); + + const chevronIcon = ownerHeader.createEl("span", { + cls: "github-issues-repo-owner-chevron", + }); + setIcon(chevronIcon, "chevron-right"); + + const ownerIcon = ownerHeader.createEl("span", { + cls: "github-issues-repo-owner-icon", + }); + setIcon(ownerIcon, "user"); + ownerHeader.createEl("span", { + cls: "github-issues-repo-owner-name", + text: ownerName, + }); + ownerHeader.createEl("span", { + cls: "github-issues-repo-count", + text: ownerProjects.length.toString(), + }); + + // Sort projects by title + ownerProjects.sort((a, b) => a.title.localeCompare(b.title)); + + const projectsListContainer = ownerContainer.createDiv( + "github-issues-owner-repos", + ); + + // Make owner header collapsible + ownerHeader.addEventListener("click", (e) => { + e.stopPropagation(); + const isExpanded = ownerContainer.classList.contains("github-issues-owner-expanded"); + if (isExpanded) { + ownerContainer.classList.remove("github-issues-owner-expanded"); + setIcon(chevronIcon, "chevron-right"); + } else { + ownerContainer.classList.add("github-issues-owner-expanded"); + setIcon(chevronIcon, "chevron-down"); + } + }); + + for (const project of ownerProjects) { + const isTracked = this.plugin.settings.trackedProjects.some( + (p) => p.id === project.id, + ); + + const projectItem = projectsListContainer.createDiv(); + projectItem.addClass("github-issues-item"); + projectItem.setAttribute("data-project-id", project.id); + projectItem.setAttribute("data-project-title", project.title.toLowerCase()); + projectItem.setAttribute("data-owner-name", project.owner.toLowerCase()); + + const projectInfoContainer = projectItem.createDiv( + "github-issues-repo-info", + ); + + if (!isTracked) { + const checkboxContainer = projectInfoContainer.createDiv( + "github-issues-repo-checkbox", + ); + const checkbox = checkboxContainer.createEl("input"); + checkbox.type = "checkbox"; + checkbox.addClass("github-issues-checkbox"); + checkbox.checked = selectedProjects.has(project.id); + + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + selectedProjects.add(project.id); + } else { + selectedProjects.delete(project.id); + } + updateSelectionUI(); + }); + } + + const projectIcon = projectInfoContainer.createDiv( + "github-issues-repo-icon", + ); + setIcon(projectIcon, "layout-dashboard"); + + const projectText = projectInfoContainer.createEl("span"); + projectText.setText(project.title); + projectText.addClass("github-issues-repo-name"); + + projectInfoContainer.createEl("span", { + text: ` #${project.number}`, + cls: "github-issues-project-number", + }); + + if (project.closed) { + projectInfoContainer.createEl("span", { + text: "Closed", + cls: "github-issues-closed-badge", + }); + } + + const actionContainer = projectItem.createDiv( + "github-issues-repo-action", + ); + + if (isTracked) { + const trackedContainer = actionContainer.createDiv( + "github-issues-tracked-container", + ); + const trackedText = trackedContainer.createEl("span"); + trackedText.setText("Tracked"); + trackedText.addClass("github-issues-info-text"); + } + } + } + + // Select all button + selectAllButton.onclick = () => { + const checkboxes = projectListContainer.querySelectorAll( + ".github-issues-checkbox", + ) as NodeListOf; + checkboxes.forEach((checkbox) => { + const projectItem = checkbox.closest(".github-issues-item"); + if ( + projectItem && + !projectItem.classList.contains("github-issues-hidden") + ) { + checkbox.checked = true; + const projectId = projectItem.getAttribute("data-project-id"); + if (projectId) { + selectedProjects.add(projectId); + } + } + }); + updateSelectionUI(); + }; + + // Select none button + selectNoneButton.onclick = () => { + const checkboxes = projectListContainer.querySelectorAll( + ".github-issues-checkbox", + ) as NodeListOf; + checkboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + selectedProjects.clear(); + updateSelectionUI(); + }; + + // Add selected button + addSelectedButton.onclick = async () => { + if (selectedProjects.size > 0) { + const existingProjects = new Map( + this.plugin.settings.trackedProjects.map(p => [p.id, p]) + ); + + for (const projectId of selectedProjects) { + if (!existingProjects.has(projectId)) { + const project = projects.find(p => p.id === projectId); + if (project) { + // Fetch status options + let statusOptions: any[] = []; + try { + statusOptions = await this.plugin.gitHubClient!.fetchProjectStatusOptions(project.id); + } catch { + // Ignore errors + } + + this.plugin.settings.trackedProjects.push({ + id: project.id, + title: project.title, + number: project.number, + url: project.url, + owner: project.owner, + enabled: true, + issueFolder: "GitHub/{project}", + statusOptions: statusOptions, + }); + } + } + } + + await this.plugin.saveSettings(); + new Notice(`Added ${selectedProjects.size} projects`); + this.display(); + } + }; + + // Search functionality + searchInput.addEventListener("input", () => { + const searchTerm = searchInput.value.toLowerCase(); + + if (searchTerm.length > 0) { + clearButton.classList.remove("github-issues-hidden"); + } else { + clearButton.classList.add("github-issues-hidden"); + } + + const projectItems = projectListContainer.querySelectorAll( + ".github-issues-item", + ); + let visibleCount = 0; + const visibleProjectsByOwner: Record = {}; + + projectItems.forEach((item) => { + const projectTitle = item.getAttribute("data-project-title") || ""; + const ownerName = item.getAttribute("data-owner-name") || ""; + + if ( + projectTitle.includes(searchTerm) || + ownerName.includes(searchTerm) + ) { + (item as HTMLElement).classList.remove("github-issues-hidden"); + visibleCount++; + if (!visibleProjectsByOwner[ownerName]) { + visibleProjectsByOwner[ownerName] = 0; + } + visibleProjectsByOwner[ownerName]++; + } else { + (item as HTMLElement).classList.add("github-issues-hidden"); + } + }); + + const ownerGroups = projectListContainer.querySelectorAll( + ".github-issues-repo-owner-group", + ); + ownerGroups.forEach((group) => { + const ownerName = group.getAttribute("data-owner") || ""; + + if ( + visibleProjectsByOwner[ownerName] && + visibleProjectsByOwner[ownerName] > 0 + ) { + (group as HTMLElement).classList.remove("github-issues-hidden"); + } else { + (group as HTMLElement).classList.add("github-issues-hidden"); + } + }); + + if (searchTerm.length > 0) { + statsCounter.setText( + `Showing ${visibleCount} of ${projects.length} projects`, + ); + } else { + statsCounter.setText(`Showing all ${projects.length} projects`); + } + + noResultsMessage.classList.toggle( + "github-issues-hidden", + visibleCount > 0, + ); + }); + + updateSelectionUI(); + } catch (error) { + container.empty(); + container.createEl("p", { + text: `Error loading projects: ${(error as Error).message}`, + }); + } + } } diff --git a/src/settings/project-list-manager.ts b/src/settings/project-list-manager.ts new file mode 100644 index 0000000..bc1ac0f --- /dev/null +++ b/src/settings/project-list-manager.ts @@ -0,0 +1,396 @@ +import { App, Notice, setIcon } from "obsidian"; +import { TrackedProject, ProjectInfo } from "../types"; +import GitHubTrackerPlugin from "../main"; + +export class ProjectListManager { + private selectedProjects: Set = new Set(); + + constructor( + private app: App, + private plugin: GitHubTrackerPlugin, + ) {} + + async addProject(project: ProjectInfo): Promise { + if ( + this.plugin.settings.trackedProjects.some( + (p) => p.id === project.id, + ) + ) { + new Notice("This project is already being tracked"); + return; + } + + const newProject: TrackedProject = { + id: project.id, + title: project.title, + number: project.number, + url: project.url, + owner: project.owner || "", + enabled: true, + }; + this.plugin.settings.trackedProjects.push(newProject); + await this.plugin.saveSettings(); + new Notice(`Added project: ${project.title}`); + } + + async addMultipleProjects(projects: ProjectInfo[]): Promise { + const newProjects: ProjectInfo[] = []; + const existingProjects: ProjectInfo[] = []; + + for (const project of projects) { + if ( + this.plugin.settings.trackedProjects.some( + (p) => p.id === project.id, + ) + ) { + existingProjects.push(project); + } else { + newProjects.push(project); + } + } + + for (const project of newProjects) { + const newProject: TrackedProject = { + id: project.id, + title: project.title, + number: project.number, + url: project.url, + owner: project.owner || "", + enabled: true, + }; + this.plugin.settings.trackedProjects.push(newProject); + } + + if (newProjects.length > 0) { + await this.plugin.saveSettings(); + } + + if (newProjects.length > 0 && existingProjects.length > 0) { + new Notice( + `Added ${newProjects.length} projects. ${existingProjects.length} were already tracked.`, + ); + } else if (newProjects.length > 0) { + new Notice(`Added ${newProjects.length} projects successfully.`); + } else if (existingProjects.length > 0) { + new Notice(`All selected projects are already being tracked.`); + } + } + + renderProjectsList( + container: HTMLElement, + onRefreshNeeded: () => void, + renderProjectSettings: (container: HTMLElement, project: TrackedProject) => void, + showDeleteModal: (project: TrackedProject) => Promise, + showBulkDeleteModal: (projects: TrackedProject[]) => Promise, + ): void { + const projectsContainer = container.createDiv( + "github-issues-repos-container", + ); + + // Add bulk actions toolbar + const bulkActionsToolbar = projectsContainer.createDiv("github-issues-bulk-actions-toolbar"); + bulkActionsToolbar.style.display = "none"; // Hidden by default + + const bulkActionInfo = bulkActionsToolbar.createDiv("github-issues-bulk-action-info"); + const selectedCountSpan = bulkActionInfo.createEl("span", { + cls: "github-issues-selected-count", + text: "0 selected" + }); + + const bulkActionButtons = bulkActionsToolbar.createDiv("github-issues-bulk-action-buttons"); + + const selectAllButton = bulkActionButtons.createEl("button", { + text: "Select all", + cls: "github-issues-select-all-button" + }); + + const deselectAllButton = bulkActionButtons.createEl("button", { + text: "Deselect all", + cls: "github-issues-deselect-all-button" + }); + + const removeSelectedButton = bulkActionButtons.createEl("button", { + cls: "github-issues-remove-selected-button mod-warning" + }); + const removeIcon = removeSelectedButton.createEl("span", { + cls: "github-issues-button-icon" + }); + setIcon(removeIcon, "trash-2"); + removeSelectedButton.createEl("span", { + cls: "github-issues-button-text", + text: "Remove selected" + }); + + // Update UI based on selection + const updateBulkActionsUI = () => { + const count = this.selectedProjects.size; + selectedCountSpan.setText(`${count} selected`); + bulkActionsToolbar.style.display = count > 0 ? "flex" : "none"; + removeSelectedButton.disabled = count === 0; + }; + + // Select/Deselect all handlers + selectAllButton.onclick = () => { + this.plugin.settings.trackedProjects.forEach(project => { + this.selectedProjects.add(project.id); + }); + // Update all checkboxes + container.querySelectorAll('.github-issues-project-checkbox').forEach(checkbox => { + checkbox.checked = true; + }); + updateBulkActionsUI(); + }; + + deselectAllButton.onclick = () => { + this.selectedProjects.clear(); + // Update all checkboxes + container.querySelectorAll('.github-issues-project-checkbox').forEach(checkbox => { + checkbox.checked = false; + }); + updateBulkActionsUI(); + }; + + // Remove selected handler + removeSelectedButton.onclick = async () => { + const projectsToDelete = this.plugin.settings.trackedProjects.filter( + project => this.selectedProjects.has(project.id) + ); + if (projectsToDelete.length > 0) { + await showBulkDeleteModal(projectsToDelete); + this.selectedProjects.clear(); + updateBulkActionsUI(); + } + }; + + const projectsByOwner: Record< + string, + { + projects: TrackedProject[]; + isUser: boolean; + } + > = {}; + + for (const project of this.plugin.settings.trackedProjects) { + const owner = project.owner || "Unknown"; + + if (!projectsByOwner[owner]) { + const isCurrentUser = + this.plugin.currentUser && + this.plugin.currentUser.toLowerCase() === + owner.toLowerCase(); + projectsByOwner[owner] = { + projects: [], + isUser: !!isCurrentUser, + }; + } + projectsByOwner[owner].projects.push(project); + } + + const sortedOwners = Object.keys(projectsByOwner).sort((a, b) => { + if (projectsByOwner[a].isUser && !projectsByOwner[b].isUser) return -1; + if (!projectsByOwner[a].isUser && projectsByOwner[b].isUser) return 1; + return a.localeCompare(b); + }); + + const projectsListContainer = projectsContainer.createDiv( + "github-issues-tracked-repos-list", + ); + const noResultsMessage = projectsContainer.createDiv( + "github-issues-no-results", + ); + const noResultsIcon = noResultsMessage.createDiv( + "github-issues-no-results-icon", + ); + setIcon(noResultsIcon, "minus-circle"); + const noResultsText = noResultsMessage.createDiv( + "github-issues-no-results-text", + ); + noResultsText.setText("No matching projects found"); + noResultsMessage.addClass("github-issues-hidden"); + + for (const owner of sortedOwners) { + const ownerContainer = projectsListContainer.createDiv( + "github-issues-repo-owner-group", + ); + ownerContainer.setAttribute("data-owner", owner.toLowerCase()); + + const ownerHeader = ownerContainer.createDiv( + "github-issues-repo-owner-header", + ); + const ownerType = projectsByOwner[owner].isUser + ? "User" + : "Organization"; + + // Chevron icon for collapse/expand + const chevronIcon = ownerHeader.createEl("span", { + cls: "github-issues-repo-owner-chevron", + }); + setIcon(chevronIcon, "chevron-right"); + + const ownerIcon = ownerHeader.createEl("span", { + cls: "github-issues-repo-owner-icon", + }); + setIcon(ownerIcon, ownerType === "User" ? "user" : "building"); + ownerHeader.createEl("span", { + cls: "github-issues-repo-owner-name", + text: owner, + }); + ownerHeader.createEl("span", { + cls: "github-issues-repo-count", + text: projectsByOwner[owner].projects.length.toString(), + }); + + const ownerProjectsContainer = ownerContainer.createDiv( + "github-issues-owner-repos", + ); + + // Make owner header collapsible + ownerHeader.addEventListener("click", (e) => { + e.stopPropagation(); + const isExpanded = ownerContainer.classList.contains("github-issues-owner-expanded"); + if (isExpanded) { + ownerContainer.classList.remove("github-issues-owner-expanded"); + setIcon(chevronIcon, "chevron-right"); + } else { + ownerContainer.classList.add("github-issues-owner-expanded"); + setIcon(chevronIcon, "chevron-down"); + } + }); + + const sortedProjects = projectsByOwner[owner].projects.sort((a, b) => { + return a.title.localeCompare(b.title); + }); + + for (const project of sortedProjects) { + const projectItem = ownerProjectsContainer.createDiv( + "github-issues-item github-issues-repo-settings", + ); + projectItem.setAttribute("data-project-id", project.id); + projectItem.setAttribute("data-owner-name", owner.toLowerCase()); + + const headerContainer = projectItem.createDiv( + "github-issues-repo-header-container", + ); + + const projectInfoContainer = headerContainer.createDiv( + "github-issues-repo-info", + ); + + // Add checkbox for bulk selection + const checkbox = projectInfoContainer.createEl("input", { + type: "checkbox", + cls: "github-issues-project-checkbox" + }); + checkbox.checked = this.selectedProjects.has(project.id); + checkbox.onclick = (e) => { + e.stopPropagation(); + if (checkbox.checked) { + this.selectedProjects.add(project.id); + } else { + this.selectedProjects.delete(project.id); + } + updateBulkActionsUI(); + }; + + const projectIcon = projectInfoContainer.createDiv( + "github-issues-repo-icon", + ); + setIcon(projectIcon, "layout-grid"); + + const projectText = projectInfoContainer.createEl("span"); + projectText.setText(project.title); + projectText.addClass("github-issues-repo-name"); + + const projectNumber = projectInfoContainer.createEl("span", { + text: ` #${project.number}`, + cls: "github-issues-project-number", + }); + + const actionContainer = headerContainer.createDiv( + "github-issues-repo-action", + ); + + const syncButton = actionContainer.createEl("button", { + text: "Sync", + }); + syncButton.addClass("github-issues-sync-button"); + syncButton.onclick = async (e) => { + e.stopPropagation(); + + // Disable button and show loading state + syncButton.disabled = true; + const originalText = syncButton.textContent || "Sync"; + syncButton.textContent = "Syncing..."; + + try { + await this.plugin.syncSingleProject(project.id); + } finally { + // Re-enable button and restore original state + syncButton.disabled = false; + syncButton.textContent = originalText; + } + }; + + const configButton = actionContainer.createEl("button", { + text: "Configure", + }); + configButton.addClass("github-issues-config-button"); + + const deleteButton = actionContainer.createEl("button"); + deleteButton.createEl("span", { + cls: "github-issues-button-icon", + text: "×", + }); + deleteButton.createEl("span", { + cls: "github-issues-button-text", + text: "Remove", + }); + deleteButton.addClass("github-issues-remove-button"); + deleteButton.onclick = async () => { + await showDeleteModal(project); + }; + + const detailsContainer = projectItem.createDiv( + "github-issues-repo-details", + ); + + // Populate detailsContainer with project settings + renderProjectSettings(detailsContainer, project); + + const toggleDetails = () => { + projectItem.classList.toggle("github-issues-expanded"); + }; + + configButton.onclick = toggleDetails; + + headerContainer.onclick = (e) => { + if ( + !(e.target as Element).closest( + ".github-issues-remove-button", + ) && + !(e.target as Element).closest( + ".github-issues-sync-button", + ) && + !(e.target as Element).closest( + ".github-issues-config-button", + ) && + !(e.target as Element).closest( + ".github-issues-project-checkbox", + ) + ) { + toggleDetails(); + } + }; + } + } + + const noTrackedProjects = projectsContainer.createEl("p", { + text: "No projects tracked. Go to 'Available Projects' tab to add projects.", + }); + noTrackedProjects.addClass("github-issues-no-repos"); + noTrackedProjects.classList.toggle( + "github-issues-hidden", + this.plugin.settings.trackedProjects.length > 0, + ); + } +} diff --git a/src/settings/project-renderer.ts b/src/settings/project-renderer.ts new file mode 100644 index 0000000..875fb13 --- /dev/null +++ b/src/settings/project-renderer.ts @@ -0,0 +1,527 @@ +import { App, Setting, setIcon } from "obsidian"; +import { TrackedProject } from "../types"; +import GitHubTrackerPlugin from "../main"; +import { FolderSuggest } from "./folder-suggest"; +import { FileSuggest } from "./file-suggest"; + +export class ProjectRenderer { + constructor( + private app: App, + private plugin: GitHubTrackerPlugin, + ) {} + + renderProjectSettings( + container: HTMLElement, + project: TrackedProject, + ): void { + const description = container.createEl("p", { + text: "Configure storage and kanban view settings for this project", + }); + description.addClass("github-issues-repo-description"); + + // ===== ISSUES STORAGE SECTION ===== + new Setting(container).setName("Issues Storage").setHeading(); + + const issueStorageContainer = container.createDiv( + "github-issues-settings-group", + ); + + // Standard issue folder (with template support) + const standardIssueFolderContainer = issueStorageContainer.createDiv(); + standardIssueFolderContainer.classList.toggle( + "github-issues-settings-hidden", + project.useCustomIssueFolder ?? false + ); + + const issueFolderSetting = new Setting(standardIssueFolderContainer) + .setName("Issues folder template") + .setDesc("Folder path template. Variables: {project}, {owner}, {project_number}") + .addText((text) => { + text + .setPlaceholder("GitHub/{project}") + .setValue(project.issueFolder || "") + .onChange(async (value) => { + project.issueFolder = value.trim() || undefined; + await this.plugin.saveSettings(); + }); + new FolderSuggest(this.app, text.inputEl); + }); + + // Use custom issue folder toggle + new Setting(issueStorageContainer) + .setName("Use custom folder for issues") + .setDesc("Use folder path directly without template variable substitution") + .addToggle((toggle) => { + toggle + .setValue(project.useCustomIssueFolder ?? false) + .onChange(async (value) => { + project.useCustomIssueFolder = value; + standardIssueFolderContainer.classList.toggle( + "github-issues-settings-hidden", + value + ); + customIssueFolderContainer.classList.toggle( + "github-issues-settings-hidden", + !value + ); + await this.plugin.saveSettings(); + }); + }); + + // Custom issue folder + const customIssueFolderContainer = issueStorageContainer.createDiv(); + customIssueFolderContainer.classList.toggle( + "github-issues-settings-hidden", + !(project.useCustomIssueFolder ?? false) + ); + + new Setting(customIssueFolderContainer) + .setName("Custom issues folder") + .setDesc("Specific folder path (used as-is without variable substitution)") + .addText((text) => { + text + .setPlaceholder("e.g., GitHub/MyProject/Issues") + .setValue(project.customIssueFolder || "") + .onChange(async (value) => { + project.customIssueFolder = value.trim() || undefined; + await this.plugin.saveSettings(); + }); + new FolderSuggest(this.app, text.inputEl); + }); + + // Issue filename template with FULL variable list + new Setting(issueStorageContainer) + .setName("Issue filename template") + .setDesc( + "Variables: {number}, {title}, {author}, {status}, {project}, {type}, {labels}, {assignees}, {owner}, {repoName}, {labels_hash}, {created}, {updated}" + ) + .addText((text) => + text + .setPlaceholder("Issue - {number}") + .setValue(project.issueNoteTemplate || "") + .onChange(async (value) => { + project.issueNoteTemplate = value.trim() || undefined; + await this.plugin.saveSettings(); + }), + ); + + // Issue Content Template Settings + new Setting(issueStorageContainer) + .setName("Use custom issue content template") + .setDesc("Enable custom template file for issue content instead of the default format") + .addToggle((toggle) => { + toggle + .setValue(project.useCustomIssueContentTemplate ?? false) + .onChange(async (value) => { + project.useCustomIssueContentTemplate = value; + customIssueTemplateContainer.classList.toggle( + "github-issues-settings-hidden", + !value + ); + await this.plugin.saveSettings(); + }); + }); + + const customIssueTemplateContainer = issueStorageContainer.createDiv( + "github-issues-settings-group github-issues-nested", + ); + customIssueTemplateContainer.classList.toggle( + "github-issues-settings-hidden", + !(project.useCustomIssueContentTemplate ?? false), + ); + + new Setting(customIssueTemplateContainer) + .setName("Issue content template file") + .setDesc("Path to a markdown file that will be used as template for issue content. See /templates folder for examples.") + .addText((text) => { + text + .setPlaceholder("templates/default-issue-template.md") + .setValue(project.issueContentTemplate || "") + .onChange(async (value) => { + project.issueContentTemplate = value.trim() || undefined; + await this.plugin.saveSettings(); + }); + + new FileSuggest(this.app, text.inputEl); + }) + .addButton((button) => { + button + .setButtonText("📄") + .setTooltip("Browse template files") + .onClick(() => { + const inputEl = button.buttonEl.parentElement?.querySelector('input'); + if (inputEl) { + inputEl.focus(); + } + }); + }); + + // ===== PULL REQUESTS STORAGE SECTION ===== + new Setting(container).setName("Pull Requests Storage").setHeading(); + + const prStorageContainer = container.createDiv( + "github-issues-settings-group", + ); + + // Standard PR folder (with template support) + const standardPrFolderContainer = prStorageContainer.createDiv(); + standardPrFolderContainer.classList.toggle( + "github-issues-settings-hidden", + project.useCustomPullRequestFolder ?? false + ); + + new Setting(standardPrFolderContainer) + .setName("Pull requests folder template") + .setDesc("Folder path template. Variables: {project}, {owner}, {project_number}") + .addText((text) => { + text + .setPlaceholder("GitHub/{project}") + .setValue(project.pullRequestFolder || "") + .onChange(async (value) => { + project.pullRequestFolder = value.trim() || undefined; + await this.plugin.saveSettings(); + }); + new FolderSuggest(this.app, text.inputEl); + }); + + // Use custom PR folder toggle + new Setting(prStorageContainer) + .setName("Use custom folder for pull requests") + .setDesc("Use folder path directly without template variable substitution") + .addToggle((toggle) => { + toggle + .setValue(project.useCustomPullRequestFolder ?? false) + .onChange(async (value) => { + project.useCustomPullRequestFolder = value; + standardPrFolderContainer.classList.toggle( + "github-issues-settings-hidden", + value + ); + customPrFolderContainer.classList.toggle( + "github-issues-settings-hidden", + !value + ); + await this.plugin.saveSettings(); + }); + }); + + // Custom PR folder + const customPrFolderContainer = prStorageContainer.createDiv(); + customPrFolderContainer.classList.toggle( + "github-issues-settings-hidden", + !(project.useCustomPullRequestFolder ?? false) + ); + + new Setting(customPrFolderContainer) + .setName("Custom pull requests folder") + .setDesc("Specific folder path (used as-is without variable substitution)") + .addText((text) => { + text + .setPlaceholder("e.g., GitHub/MyProject/Pull Requests") + .setValue(project.customPullRequestFolder || "") + .onChange(async (value) => { + project.customPullRequestFolder = value.trim() || undefined; + await this.plugin.saveSettings(); + }); + new FolderSuggest(this.app, text.inputEl); + }); + + // PR filename template with FULL variable list + new Setting(prStorageContainer) + .setName("PR filename template") + .setDesc( + "Variables: {number}, {title}, {author}, {status}, {project}, {type}, {labels}, {assignees}, {owner}, {repoName}, {labels_hash}, {created}, {updated}" + ) + .addText((text) => + text + .setPlaceholder("PR - {number}") + .setValue(project.pullRequestNoteTemplate || "") + .onChange(async (value) => { + project.pullRequestNoteTemplate = value.trim() || undefined; + await this.plugin.saveSettings(); + }), + ); + + // PR Content Template Settings + new Setting(prStorageContainer) + .setName("Use custom PR content template") + .setDesc("Enable custom template file for PR content instead of the default format") + .addToggle((toggle) => { + toggle + .setValue(project.useCustomPullRequestContentTemplate ?? false) + .onChange(async (value) => { + project.useCustomPullRequestContentTemplate = value; + customPRTemplateContainer.classList.toggle( + "github-issues-settings-hidden", + !value + ); + await this.plugin.saveSettings(); + }); + }); + + const customPRTemplateContainer = prStorageContainer.createDiv( + "github-issues-settings-group github-issues-nested", + ); + customPRTemplateContainer.classList.toggle( + "github-issues-settings-hidden", + !(project.useCustomPullRequestContentTemplate ?? false), + ); + + new Setting(customPRTemplateContainer) + .setName("PR content template file") + .setDesc("Path to a markdown file that will be used as template for PR content. See /templates folder for examples.") + .addText((text) => { + text + .setPlaceholder("templates/default-pr-template.md") + .setValue(project.pullRequestContentTemplate || "") + .onChange(async (value) => { + project.pullRequestContentTemplate = value.trim() || undefined; + await this.plugin.saveSettings(); + }); + + new FileSuggest(this.app, text.inputEl); + }) + .addButton((button) => { + button + .setButtonText("📄") + .setTooltip("Browse template files") + .onClick(() => { + const inputEl = button.buttonEl.parentElement?.querySelector('input'); + if (inputEl) { + inputEl.focus(); + } + }); + }); + + // Kanban View Settings Section + new Setting(container).setName("Kanban View Settings").setHeading(); + + const kanbanSettingsContainer = container.createDiv( + "github-issues-settings-group", + ); + + new Setting(kanbanSettingsContainer) + .setName("Customize columns") + .setDesc("Reorder and hide status columns") + .addToggle((toggle) => + toggle + .setValue(project.useCustomStatusOrder ?? false) + .onChange(async (value) => { + project.useCustomStatusOrder = value; + statusOrderContainer.classList.toggle( + "github-issues-settings-hidden", + !value, + ); + await this.plugin.saveSettings(); + }), + ); + + // Status order container (only visible when custom order is enabled) + const statusOrderContainer = kanbanSettingsContainer.createDiv( + "github-issues-settings-group github-issues-nested", + ); + statusOrderContainer.classList.toggle( + "github-issues-settings-hidden", + !(project.useCustomStatusOrder ?? false), + ); + + // Refresh from GitHub button + const refreshSetting = new Setting(statusOrderContainer) + .setName("Status columns") + .setDesc("Drag to reorder, toggle visibility, or refresh from GitHub"); + + refreshSetting.addButton((button) => { + button + .setButtonText("Refresh from GitHub") + .setTooltip("Reload status options from GitHub") + .onClick(async () => { + button.setDisabled(true); + button.setButtonText("Loading..."); + try { + const statusOptions = await this.plugin.gitHubClient?.fetchProjectStatusOptions(project.id); + if (statusOptions) { + project.statusOptions = statusOptions; + // Update custom order if it exists + if (project.useCustomStatusOrder) { + project.customStatusOrder = statusOptions.map((opt: any) => opt.name); + } + await this.plugin.saveSettings(); + // Re-render the status list + this.renderStatusList(statusListContainer, project); + } + } finally { + button.setDisabled(false); + button.setButtonText("Refresh from GitHub"); + } + }); + }); + + // Status list container + const statusListContainer = statusOrderContainer.createDiv( + "github-issues-status-order-list", + ); + this.renderStatusList(statusListContainer, project); + + new Setting(kanbanSettingsContainer) + .setName("Show empty columns") + .setDesc("Display status columns even when they have no items") + .addToggle((toggle) => + toggle + .setValue(project.showEmptyColumns ?? true) + .onChange(async (value) => { + project.showEmptyColumns = value; + await this.plugin.saveSettings(); + }), + ); + + new Setting(kanbanSettingsContainer) + .setName("Skip hidden statuses on sync") + .setDesc("Don't download issues/PRs with hidden status columns") + .addToggle((toggle) => + toggle + .setValue(project.skipHiddenStatusesOnSync ?? false) + .onChange(async (value) => { + project.skipHiddenStatusesOnSync = value; + await this.plugin.saveSettings(); + }), + ); + } + + private renderStatusList(container: HTMLElement, project: TrackedProject): void { + container.empty(); + + // Get status order + let statusOrder: string[]; + if (project.useCustomStatusOrder && project.customStatusOrder?.length) { + statusOrder = [...project.customStatusOrder]; + } else if (project.statusOptions?.length) { + statusOrder = project.statusOptions.map(opt => opt.name); + } else { + statusOrder = []; + } + + const hiddenStatuses = new Set(project.hiddenStatuses || []); + + if (statusOrder.length === 0) { + const emptyMessage = container.createEl("p", { + text: "No status columns found. Click 'Refresh from GitHub' to load.", + }); + emptyMessage.style.color = "var(--text-muted)"; + emptyMessage.style.fontStyle = "italic"; + emptyMessage.style.padding = "8px"; + return; + } + + for (let i = 0; i < statusOrder.length; i++) { + const status = statusOrder[i]; + const isHidden = hiddenStatuses.has(status); + + const statusItem = container.createDiv("github-issues-status-item"); + statusItem.setAttribute("data-index", i.toString()); + statusItem.setAttribute("data-status", status); + statusItem.draggable = true; + + // Drag handle + const dragHandle = statusItem.createEl("span", { + cls: "github-issues-status-drag-handle", + }); + setIcon(dragHandle, "grip-vertical"); + + // Status name + const statusName = statusItem.createEl("span", { + text: status, + cls: "github-issues-status-name", + }); + if (isHidden) { + statusName.addClass("github-issues-status-hidden"); + } + + // Move buttons + const moveContainer = statusItem.createDiv("github-issues-status-move-buttons"); + + const moveUpBtn = moveContainer.createEl("button", { + cls: "github-issues-status-move-btn", + }); + setIcon(moveUpBtn, "chevron-up"); + moveUpBtn.disabled = i === 0; + moveUpBtn.onclick = async (e) => { + e.stopPropagation(); + if (i > 0) { + [statusOrder[i - 1], statusOrder[i]] = [statusOrder[i], statusOrder[i - 1]]; + project.customStatusOrder = statusOrder; + project.useCustomStatusOrder = true; + await this.plugin.saveSettings(); + this.renderStatusList(container, project); + } + }; + + const moveDownBtn = moveContainer.createEl("button", { + cls: "github-issues-status-move-btn", + }); + setIcon(moveDownBtn, "chevron-down"); + moveDownBtn.disabled = i === statusOrder.length - 1; + moveDownBtn.onclick = async (e) => { + e.stopPropagation(); + if (i < statusOrder.length - 1) { + [statusOrder[i], statusOrder[i + 1]] = [statusOrder[i + 1], statusOrder[i]]; + project.customStatusOrder = statusOrder; + project.useCustomStatusOrder = true; + await this.plugin.saveSettings(); + this.renderStatusList(container, project); + } + }; + + // Visibility toggle + const visibilityBtn = statusItem.createEl("button", { + cls: "github-issues-status-visibility-btn", + }); + setIcon(visibilityBtn, isHidden ? "eye-off" : "eye"); + visibilityBtn.title = isHidden ? "Show column" : "Hide column"; + visibilityBtn.onclick = async (e) => { + e.stopPropagation(); + if (isHidden) { + hiddenStatuses.delete(status); + } else { + hiddenStatuses.add(status); + } + project.hiddenStatuses = Array.from(hiddenStatuses); + await this.plugin.saveSettings(); + this.renderStatusList(container, project); + }; + + // Drag and drop handlers + statusItem.ondragstart = (e) => { + e.dataTransfer?.setData("text/plain", i.toString()); + statusItem.addClass("dragging"); + }; + + statusItem.ondragend = () => { + statusItem.removeClass("dragging"); + }; + + statusItem.ondragover = (e) => { + e.preventDefault(); + statusItem.addClass("drag-over"); + }; + + statusItem.ondragleave = () => { + statusItem.removeClass("drag-over"); + }; + + statusItem.ondrop = async (e) => { + e.preventDefault(); + statusItem.removeClass("drag-over"); + const fromIndex = parseInt(e.dataTransfer?.getData("text/plain") || "0"); + const toIndex = i; + if (fromIndex !== toIndex) { + const [movedItem] = statusOrder.splice(fromIndex, 1); + statusOrder.splice(toIndex, 0, movedItem); + project.customStatusOrder = statusOrder; + project.useCustomStatusOrder = true; + await this.plugin.saveSettings(); + this.renderStatusList(container, project); + } + }; + } + } +} diff --git a/src/types.ts b/src/types.ts index 9c0bfd8..755a5bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,14 +48,39 @@ export interface ProjectInfo { owner?: string; // Owner (user or org) of the project } -// Tracked project configuration +// Status option from GitHub Projects +export interface ProjectStatusOption { + id: string; + name: string; + color?: string; + description?: string; +} + export interface TrackedProject { id: string; title: string; number: number; url: string; - owner: string; // User or organization that owns the project - enabled: boolean; // Whether this project is being tracked + owner: string; + enabled: boolean; + issueFolder?: string; + useCustomIssueFolder?: boolean; + customIssueFolder?: string; + pullRequestFolder?: string; + useCustomPullRequestFolder?: boolean; + customPullRequestFolder?: string; + issueNoteTemplate?: string; + pullRequestNoteTemplate?: string; + useCustomIssueContentTemplate?: boolean; + issueContentTemplate?: string; + useCustomPullRequestContentTemplate?: boolean; + pullRequestContentTemplate?: string; + statusOptions?: ProjectStatusOption[]; + customStatusOrder?: string[]; + useCustomStatusOrder?: boolean; + showEmptyColumns?: boolean; + hiddenStatuses?: string[]; + skipHiddenStatusesOnSync?: boolean; } // GitHub Projects v2 types @@ -159,7 +184,7 @@ export const DEFAULT_SETTINGS: GitHubTrackerSettings = { backgroundSyncInterval: 30, cleanupClosedIssuesDays: 30, globalDefaults: DEFAULT_GLOBAL_DEFAULTS, - enableProjectTracking: false, + enableProjectTracking: true, trackedProjects: [], }; diff --git a/src/util/persistUtils.ts b/src/util/persistUtils.ts index 3558d80..e970aad 100644 --- a/src/util/persistUtils.ts +++ b/src/util/persistUtils.ts @@ -160,10 +160,21 @@ function insertPersistBlocksIntelligently( // Try to find these context lines in the new content let insertIndex = -1; - // Look for the last context line before the block + // First, find where the frontmatter ends in the new content + let frontmatterEnd = 0; + if (newLines[0]?.trim() === '---') { + for (let i = 1; i < newLines.length; i++) { + if (newLines[i]?.trim() === '---') { + frontmatterEnd = i + 1; + break; + } + } + } + + // Look for the last context line before the block (but only outside frontmatter) if (contextBefore.length > 0) { const lastContextLine = contextBefore[contextBefore.length - 1]; - for (let i = 0; i < newLines.length; i++) { + for (let i = frontmatterEnd; i < newLines.length; i++) { if (newLines[i].trim() === lastContextLine) { insertIndex = i + 1; break; @@ -171,10 +182,10 @@ function insertPersistBlocksIntelligently( } } - // If we couldn't find context before, look for context after + // If we couldn't find context before, look for context after (but only outside frontmatter) if (insertIndex === -1 && contextAfter.length > 0) { const firstContextAfter = contextAfter[0]; - for (let i = 0; i < newLines.length; i++) { + for (let i = frontmatterEnd; i < newLines.length; i++) { if (newLines[i].trim() === firstContextAfter) { insertIndex = i; break; @@ -183,22 +194,12 @@ function insertPersistBlocksIntelligently( } // Strategy 2: If no context found, use relative position - if (insertIndex === -1) { + if (insertIndex === -1 || insertIndex < frontmatterEnd) { // Calculate relative position (percentage through the document) const relativePosition = oldLineNumber / oldLines.length; insertIndex = Math.floor(newLines.length * relativePosition); // Make sure we're not in the frontmatter - let frontmatterEnd = 0; - if (newLines[0]?.trim() === '---') { - for (let i = 1; i < newLines.length; i++) { - if (newLines[i]?.trim() === '---') { - frontmatterEnd = i + 1; - break; - } - } - } - if (insertIndex < frontmatterEnd) { insertIndex = frontmatterEnd; } diff --git a/styles.css b/styles.css index 532aac7..7ba7597 100644 --- a/styles.css +++ b/styles.css @@ -1767,149 +1767,552 @@ button:disabled { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; - padding-bottom: 10px; - border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 16px; + padding: 5px 20px; + background: var(--background-secondary); + border-radius: 8px; + border: 1px solid var(--background-modifier-border); } .github-kanban-header h2 { margin: 0; - font-size: 1.5em; - font-weight: 600; + font-size: 1.4em; + font-weight: 700; + color: var(--text-normal); + display: flex; + align-items: center; + gap: 8px; } .github-kanban-refresh-btn { - padding: 6px 12px; - background-color: var(--interactive-normal); - color: var(--text-normal); + padding: 8px 14px; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: all 0.2s ease; +} + +.github-kanban-refresh-btn:hover { + background-color: var(--interactive-accent-hover); + transform: translateY(-1px); +} + +.github-kanban-refresh-btn svg { + width: 14px; + height: 14px; +} + +/* Tab Navigation */ +.github-kanban-tabs { + display: flex; + gap: 6px; + margin-bottom: 20px; + padding: 8px; + background: var(--background-secondary); + border-radius: 8px; border: 1px solid var(--background-modifier-border); - border-radius: 4px; + flex-wrap: wrap; +} + +.github-kanban-tab { + padding: 10px 18px; + border: none; + border-radius: 6px; cursor: pointer; font-size: 0.9em; + font-weight: 500; + transition: all 0.2s ease; + background-color: transparent; + color: var(--text-muted); } -.github-kanban-refresh-btn:hover { - background-color: var(--interactive-hover); +.github-kanban-tab:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); +} + +.github-kanban-tab.active, +.github-kanban-tab[style*="interactive-accent"] { + background-color: var(--interactive-accent) !important; + color: var(--text-on-accent) !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); +} + +/* Loading State */ +.github-kanban-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.github-kanban-loading p { + font-size: 1em; + margin: 0; } .github-kanban-empty { text-align: center; color: var(--text-muted); - font-style: italic; - padding: 40px; + padding: 60px 40px; + background: var(--background-secondary); + border-radius: 8px; + border: 1px dashed var(--background-modifier-border); } +/* Project Board */ .github-kanban-project { - margin-bottom: 30px; + margin-bottom: 24px; } .github-kanban-project h3 { - margin: 0 0 15px 0; - font-size: 1.2em; + margin: 0 0 16px 0; + font-size: 1.1em; font-weight: 600; - color: var(--text-normal); - padding-bottom: 5px; - border-bottom: 1px solid var(--background-modifier-border); + color: var(--text-muted); + display: flex; + align-items: center; + gap: 8px; } .github-kanban-board { display: flex; - gap: 15px; + gap: 16px; overflow-x: auto; - padding: 10px 0; + padding: 8px 4px 16px 4px; + scroll-behavior: smooth; +} + +.github-kanban-board::-webkit-scrollbar { + height: 8px; +} + +.github-kanban-board::-webkit-scrollbar-track { + background: var(--background-secondary); + border-radius: 4px; +} + +.github-kanban-board::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 4px; +} + +.github-kanban-board::-webkit-scrollbar-thumb:hover { + background: var(--background-modifier-border-hover); } +/* Columns */ .github-kanban-column { - flex: 0 0 280px; - background-color: var(--background-primary); + flex: 0 0 300px; + background-color: var(--background-secondary); border: 1px solid var(--background-modifier-border); - border-radius: 8px; - padding: 12px; - min-height: 400px; + border-radius: 10px; + padding: 0; + min-height: 200px; + max-height: 70vh; + display: flex; + flex-direction: column; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } .github-kanban-column h4 { - margin: 0 0 12px 0; - font-size: 1em; + margin: 0; + font-size: 0.9em; font-weight: 600; color: var(--text-normal); - text-align: center; - padding: 8px; - background-color: var(--background-secondary); - border-radius: 4px; + text-align: left; + padding: 14px 16px; + background-color: var(--background-primary); + border-radius: 10px 10px 0 0; + border-bottom: 1px solid var(--background-modifier-border); + position: sticky; + top: 0; + z-index: 1; + display: flex; + justify-content: space-between; + align-items: center; } .github-kanban-items { display: flex; flex-direction: column; - gap: 8px; + gap: 10px; + padding: 12px; + overflow-y: auto; + flex: 1; } +/* Cards */ .github-kanban-item { - background-color: var(--background-secondary); + background-color: var(--background-primary); border: 1px solid var(--background-modifier-border); - border-radius: 6px; - padding: 12px; + border-radius: 8px; + padding: 14px; cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: all 0.15s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .github-kanban-item:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); border-color: var(--interactive-accent); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); } .github-kanban-item-title { font-weight: 600; color: var(--text-normal); - margin-bottom: 6px; - line-height: 1.3; - font-size: 0.95em; + margin-bottom: 8px; + line-height: 1.4; + font-size: 0.9em; + word-wrap: break-word; } .github-kanban-item-meta { font-size: 0.8em; color: var(--text-muted); - margin-bottom: 8px; + margin-bottom: 6px; +} + +.github-kanban-item-meta svg { + width: 12px; + height: 12px; + opacity: 0.7; } .github-kanban-item-labels { display: flex; flex-wrap: wrap; - gap: 4px; - margin-top: 8px; + gap: 5px; + margin-top: 10px; } .github-kanban-label { - padding: 2px 6px; - border-radius: 10px; - font-size: 0.75em; - font-weight: 500; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.7em; + font-weight: 600; color: white; - text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); +} + +/* Collapsible details */ +.github-kanban-item details { + margin-top: 8px; + font-size: 0.75em; +} + +.github-kanban-item details summary { + cursor: pointer; + color: var(--text-muted); + user-select: none; +} + +.github-kanban-item details summary:hover { + color: var(--text-normal); +} + +.github-kanban-item details pre { + margin-top: 8px; + padding: 8px; + background: var(--background-secondary); + border-radius: 4px; + overflow-x: auto; + font-size: 0.9em; + max-height: 200px; + overflow-y: auto; } /* Dark theme adjustments */ +.theme-dark .github-kanban-column { + background-color: var(--background-primary-alt); +} + .theme-dark .github-kanban-item { - background-color: var(--background-primary); + background-color: var(--background-secondary); } .theme-dark .github-kanban-column h4 { - background-color: var(--background-primary); + background-color: var(--background-secondary); } /* Responsive design */ @media (max-width: 768px) { + .github-kanban-tabs { + padding: 6px; + } + + .github-kanban-tab { + padding: 8px 12px; + font-size: 0.85em; + } + .github-kanban-board { flex-direction: column; - gap: 10px; + gap: 12px; } .github-kanban-column { flex: none; width: 100%; - min-height: 300px; + min-height: auto; + max-height: none; + } + + .github-kanban-header { + flex-direction: column; + gap: 12px; + text-align: center; } } + +/* Project status order list */ +.github-issues-status-order-list { + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 8px; + margin-bottom: 16px; + min-height: 60px; + background: var(--background-secondary); +} + +.github-issues-status-item { + display: flex; + align-items: center; + padding: 8px 10px; + margin-bottom: 4px; + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + gap: 8px; + cursor: grab; + transition: all 0.15s ease; +} + +.github-issues-status-item:hover { + background-color: var(--background-modifier-hover); + border-color: var(--interactive-accent); +} + +.github-issues-status-item.dragging { + opacity: 0.5; + cursor: grabbing; +} + +.github-issues-status-item.drag-over { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} + +.github-issues-status-drag-handle { + color: var(--text-muted); + cursor: grab; + display: flex; + align-items: center; +} + +.github-issues-status-name { + flex: 1; + font-weight: 500; +} + +.github-issues-status-name.github-issues-status-hidden { + text-decoration: line-through; + opacity: 0.5; +} + +.github-issues-status-move-buttons { + display: flex; + gap: 4px; +} + +.github-issues-status-move-btn { + padding: 4px; + border: none; + background: transparent; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.15s ease; +} + +.github-issues-status-move-btn:hover:not(:disabled) { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.github-issues-status-move-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.github-issues-status-visibility-btn { + padding: 4px; + border: none; + background: transparent; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.15s ease; +} + +.github-issues-status-visibility-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* Project number in header */ +.github-issues-project-number { + color: var(--text-muted); + font-size: 0.85em; + margin-left: 4px; +} + +/* Kanban inline styles moved to CSS */ +.github-kanban-tabs-inline { + display: flex; + gap: 4px; + margin-bottom: 16px; + border-bottom: 1px solid var(--background-modifier-border); + padding-bottom: 8px; + flex-wrap: wrap; + align-items: center; +} + +.github-kanban-tab-styled { + padding: 8px 16px; + border: none; + border-radius: 4px 4px 0 0; + cursor: pointer; + background-color: var(--background-secondary); + color: var(--text-normal); +} + +.github-kanban-tab-styled.active { + background-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.github-kanban-spacer { + flex-grow: 1; +} + +.github-kanban-refresh-styled { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: none; + border-radius: 4px; + cursor: pointer; + background-color: var(--background-secondary); + color: var(--text-muted); +} + +.github-kanban-loading-styled { + text-align: center; + padding: 40px; +} + +.github-kanban-item-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.github-kanban-item-type { + font-size: 0.85em; + color: var(--text-muted); +} + +.github-kanban-item-title { + font-weight: 600; + margin-bottom: 6px; +} + +.github-kanban-item-labels { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 6px; +} + +.github-kanban-label { + padding: 2px 6px; + border-radius: 10px; + font-size: 0.75em; + font-weight: 500; +} + +.github-kanban-label-more { + font-size: 0.75em; + color: var(--text-muted); +} + +.github-kanban-item-creator { + font-size: 0.85em; + color: var(--text-muted); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 4px; +} + +.github-kanban-user-icon { + display: flex; +} + +.github-kanban-item-description { + font-size: 0.85em; + color: var(--text-muted); + line-height: 1.4; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +/* Settings inline styles moved to CSS */ +.github-issues-escaping-info { + margin-top: 8px; +} + +.github-issues-escaping-summary { + cursor: pointer; + font-weight: 500; +} + +.github-issues-escaping-content { + margin-top: 8px; + padding-left: 12px; +} + +.github-issues-section-margin { + margin-top: 30px; +} + +.github-issues-checkbox-hidden { + visibility: hidden; +} + +.github-issues-closed-badge { + color: var(--text-muted); + font-size: 11px; + margin-left: 8px; + padding: 2px 6px; + background-color: var(--background-modifier-border); + border-radius: 4px; +} From 0a5b1a008daf113dedd5ffcaeb199a3288dd0c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Platte?= Date: Mon, 29 Dec 2025 22:34:09 +0100 Subject: [PATCH 3/4] docs: update Readme & Template Reference --- .github/dependabot.yml | 2 +- README.md | 31 ++++++++++-- src/util/templateUtils.ts | 3 +- templates/Template Variables Reference.md | 45 ++++++++++++++++- templates/detailed-template.md | 2 +- templates/project-template.md | 59 +++++++++++++++++++++++ 6 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 templates/project-template.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4c6de24..0446800 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,7 @@ updates: - package-ecosystem: npm directory: "/" schedule: - interval: monthly + interval: weekly open-pull-requests-limit: 10 labels: - Dependencies diff --git a/README.md b/README.md index 9c18b18..f8dc7d0 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,32 @@ An Obsidian plugin that integrates with GitHub to track issues and pull requests directly in your vault. -The configurations are heavily inspired by https://github.com/schaier-io, including some specific settings. However, I had already started working on my prototype before I discovered the plugin, and had initially even given it a similar name. + +>The configurations are heavily inspired by https://github.com/schaier-io, including some specific settings. However, I had already started working on my prototype before I discovered the plugin, and had initially even given it a similar name. ## ✨ Features -- Track issues and pull requests from specific GitHub repositories -- Automatically sync GitHub data on Obsidian startup (configurable) -- Filter tracked items by assignee and reviewers -- Create Markdown notes for each issue or pull request +### 🔄 Issue & Pull Request Tracking +- Track issues and pull requests from multiple GitHub repositories +- Automatically sync GitHub data on startup (configurable) +- Background sync at configurable intervals +- Filter by labels, assignees, and reviewers +- Include or exclude closed issues/PRs +- Automatic cleanup of old closed items + +### 📊 GitHub Projects v2 Integration +- Track GitHub Projects across repositories +- Kanban board view for project visualization +- Custom field support (status, priority, iteration) +- Project-specific filtering and organization + +### 📝 Markdown Notes +- Create markdown notes for each issue or PR +- Customizable filename templates with variables +- Custom content templates +- YAML frontmatter with metadata +- Preserve user content with persist blocks +- Include comments in notes ## 🚀 Installation @@ -43,6 +61,9 @@ The configurations are heavily inspired by https://github.com/schaier-io, includ 3. Click **Add Repository** or **Add Selected Repositories** 4. The plugin will automatically fetch issues from the configured repositories +### ⭐ This repository if you like this project! + + ## 📄 License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/src/util/templateUtils.ts b/src/util/templateUtils.ts index 2af1d77..1f79c71 100644 --- a/src/util/templateUtils.ts +++ b/src/util/templateUtils.ts @@ -323,7 +323,8 @@ function escapeRegExp(string: string): string { */ function processConditionalBlocks(template: string, data: TemplateData): string { // Pattern: {variableName:content} - show content only if variableName has a value - const conditionalPattern = /\{(\w+):(.*?)\}/g; + // This pattern supports nested variables like {project:{project_url}} + const conditionalPattern = /\{(\w+):([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g; return template.replace(conditionalPattern, (match, variableName, content) => { // Check if the variable exists and has a meaningful value diff --git a/templates/Template Variables Reference.md b/templates/Template Variables Reference.md index a748239..d86a843 100644 --- a/templates/Template Variables Reference.md +++ b/templates/Template Variables Reference.md @@ -68,8 +68,51 @@ | `{lockReason}` | Reason for locking | "resolved", "spam", "off-topic" | | `{comments}` | Formatted comments section | Complete comments with formatting | +## GitHub Projects + +These variables are available when the issue/PR is part of a GitHub Project (Projects V2). + +### Basic Project Information + +| Variable | Description | Example | +|----------|-------------|---------| +| `{project}` | Project title (first project if in multiple) | "Sprint Board" | +| `{project_url}` | Project URL | "https://github.com/orgs/owner/projects/1" | +| `{project_number}` | Project number | "1" | +| `{project_status}` | Status field value | "In Progress", "Done" | +| `{project_priority}` | Priority field value | "High", "Medium", "Low" | + +### Iteration Information + +| Variable | Description | Example | +|----------|-------------|---------| +| `{project_iteration}` | Current iteration title | "Sprint 5" | +| `{project_iteration_start}` | Iteration start date | "2025-01-15" | +| `{project_iteration_duration}` | Iteration duration in days | "14" | + +### Multiple Projects + +| Variable | Description | Example | +|----------|-------------|---------| +| `{projects}` | All project names as comma-separated list | "Sprint Board, Backlog" | +| `{projects_yaml}` | All project names as YAML array | `["Sprint Board", "Backlog"]` | + +### Custom Fields + +| Variable | Description | Example | +|----------|-------------|---------| +| `{project_fields}` | All custom fields as YAML | ` Effort: "5"` (with newlines) | +| `{project_field:FieldName}` | Access specific custom field by name | `{project_field:Effort}` → "5" | + ## Conditional Blocks | Syntax | Description | Example | |--------|-------------|---------| -| `{variable:content}` | Shows content only if variable has a value | `Milestone: {milestone}}` | +| `{variable:content}` | Shows content only if variable has a value | `{milestone:Milestone: {milestone}}` | + +### Project-related Conditionals + +| Syntax | Description | +|--------|-------------| +| `{project:content}` | Shows content only if item is in a project | +| `{projects:content}` | Shows content if item is in any project | diff --git a/templates/detailed-template.md b/templates/detailed-template.md index 31d6c44..dae4afb 100644 --- a/templates/detailed-template.md +++ b/templates/detailed-template.md @@ -45,4 +45,4 @@ allowDelete: true --- -*Auto-generated from GitHub {type} data* +*Last updated: {updated}* diff --git a/templates/project-template.md b/templates/project-template.md new file mode 100644 index 0000000..3a2b11b --- /dev/null +++ b/templates/project-template.md @@ -0,0 +1,59 @@ +--- +title: "{title_yaml}" +number: {number} +status: "{status}" +type: "{type}" +repository: "{repository}" +created: "{created}" +author: "{author}" +assignees: {assignees_yaml} +labels: {labels_yaml} +project: "{project}" +project_status: "{project_status}" +project_priority: "{project_priority}" +project_iteration: "{project_iteration}" +updateMode: "none" +allowDelete: true +--- + +# {title} + +**{type} #{number}** in **{repository}** + +{project:## Project + +| Field | Value | +|-------|-------| +| **Project** | [{project}]({project_url}) | +| **Status** | {project_status} | +| **Priority** | {project_priority} | +| **Iteration** | {project_iteration} | +} +## Summary + +{body} + +## People + +- **Author:** @{author} +- **Assignees:** {assignees} + +## Classification + +- **Status:** `{status}` +- **Labels:** {labels} +- **Milestone:** {milestone} +## Dates + +- **Created:** {created} +- **Updated:** {updated} +- **Closed:** {closed} +## Links + +[View on GitHub]({url}){project: | [View in Project]({project_url})} + +{comments} + +--- + +*Last updated: {updated}* From e7b98a79e80794d62c8dfbc4669e29a1702aad35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Platte?= Date: Mon, 29 Dec 2025 22:40:16 +0100 Subject: [PATCH 4/4] chore: bump dependencies --- .github/dependabot.yml | 1 + package.json | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0446800..4d63bca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,6 +2,7 @@ version: 2 updates: - package-ecosystem: npm directory: "/" + target-branch: develop schedule: interval: weekly open-pull-requests-limit: 10 diff --git a/package.json b/package.json index f7a8484..5bf6204 100644 --- a/package.json +++ b/package.json @@ -19,19 +19,19 @@ "author": "LonoxX", "license": "MIT", "devDependencies": { - "@types/node": "^24.9.1", - "@typescript-eslint/eslint-plugin": "^8.46.2", - "@typescript-eslint/parser": "^8.46.2", + "@types/node": "^25.0.3", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", "builtin-modules": "^5.0.0", - "esbuild": "^0.25.11", - "eslint": "^9.38.0", + "esbuild": "^0.27.2", + "eslint": "^9.39.2", "obsidian": "latest", "tslib": "^2.8.1", "typescript": "^5.9.3" }, "dependencies": { "date-fns": "^4.1.0", - "octokit": "^5.0.4", - "prettier": "^3.6.2" + "octokit": "^5.0.5", + "prettier": "^3.7.4" } }