diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4c6de24..4d63bca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,8 +2,9 @@ version: 2 updates: - package-ecosystem: npm directory: "/" + target-branch: develop 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/package.json b/package.json index 87b9d92..40d7ceb 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.27.0", - "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" } } 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/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 1f38ff9..dec4632 100644 --- a/src/github-client.ts +++ b/src/github-client.ts @@ -1,6 +1,17 @@ -import { GitHubTrackerSettings } from "./types"; +import { GitHubTrackerSettings, ProjectData, ProjectFieldValue, ProjectInfo, ProjectStatusOption } 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, + GET_PROJECT_FIELDS, + parseItemProjectData, + ProjectItemData, +} from "./github-graphql"; export class GitHubClient { private octokit: Octokit | null = null; @@ -564,6 +575,469 @@ 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 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) + */ + public async fetchProjectsForRepository( + owner: string, + repo: string, + ): Promise { + if (!this.octokit) { + return []; + } + + const projects: ProjectInfo[] = []; + + try { + // First, try to get repository-linked projects + let hasNextPage = true; + let cursor: string | null = null; + + while (hasNextPage) { + 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; + } + + // Also try to get organization projects if the owner is an org + try { + 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; + } + } 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 { + 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; + } + } catch { + // Owner is an org, not a user - that's fine + } + + this.noticeManager.debug( + `Found ${projects.length} projects for ${owner}/${repo}`, + ); + } 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 []; + } + } + + /** + * 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 new file mode 100644 index 0000000..0817f48 --- /dev/null +++ b/src/github-graphql.ts @@ -0,0 +1,469 @@ +/** + * 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) { + 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_INFO_FRAGMENT} + ${FIELD_VALUES_FRAGMENT} + } + } + } + ... on PullRequest { + projectItems(first: 10) { + nodes { + id + ${PROJECT_INFO_FRAGMENT} + ${FIELD_VALUES_FRAGMENT} + } + } + } + } +} +`; + +// 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_INFO_FRAGMENT} + ${FIELD_VALUES_FRAGMENT} + } + } + } + ... on PullRequest { + id + number + projectItems(first: 10) { + nodes { + id + ${PROJECT_INFO_FRAGMENT} + ${FIELD_VALUES_FRAGMENT} + } + } + } + } +} +`; + +// 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 + 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) { + 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..ccfe812 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,41 @@ 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 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 tracked projects from global settings + if (projectData && trackedProjectIds.length > 0) { + projectData = projectData.filter(p => + trackedProjectIds.includes(p.projectId) + ); + } + await this.createOrUpdateIssueFile( effectiveRepo, ownerCleaned, repoCleaned, issue, + projectData, ); } } @@ -72,6 +100,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 +141,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 +174,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..bc5c7be --- /dev/null +++ b/src/kanban-view.ts @@ -0,0 +1,611 @@ +import { ItemView, WorkspaceLeaf, TFile, Notice, setIcon } from "obsidian"; +import { GitHubTrackerSettings, ProjectData, TrackedProject } 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 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; + 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 "square-kanban"; + } + + async onOpen(): Promise { + // Don't load all projects at startup - just render the UI + 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(); + + // Get tracked projects + const trackedProjects = this.settings.trackedProjects || []; + + if (trackedProjects.length === 0) { + container.createEl("p", { + text: "No projects tracked. Go to Settings → GitHub Projects to add projects.", + cls: "github-kanban-empty" + }); + return; + } + + // 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; + } + + // 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"); + + 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); + + // Get sorted statuses based on settings + const sortedStatuses = this.getSortedStatuses(project.id, statusColumns); + + for (const status of sortedStatuses) { + const columnItems = statusColumns.get(status) || []; + this.renderColumn(boardContainer, status, columnItems); + } + } + + private getSortedStatuses(projectId: string, statusColumns: Map): string[] { + const statusesWithItems = Array.from(statusColumns.keys()); + + // Find the tracked project settings + const trackedProject = this.settings.trackedProjects?.find(p => p.id === projectId); + + if (!trackedProject) { + // Fallback: alphabetical with "No Status" at end + return this.defaultStatusSort(statusesWithItems); + } + + // 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); + } + + if (statusOrder.length === 0) { + // No order defined, use default + return this.defaultStatusSort(statusesWithItems); + } + + // Check settings + const showEmptyColumns = trackedProject.showEmptyColumns ?? true; + const hiddenStatuses = new Set(trackedProject.hiddenStatuses || []); + + 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); + } + + // 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[] = []; + + 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 { + if (hasAnyProjectFolder && !isFileInProjectFolder(file.path)) { + continue; + } + + const content = await this.app.vault.read(file); + const frontmatter = this.parseFrontmatter(content); + if (!frontmatter) continue; + + const isIssue = frontmatter.number && frontmatter.title && frontmatter.state; + if (!isIssue) continue; + + const isInProjectFolder = hasAnyProjectFolder; + const fileMatchesProject = frontmatter.project === project.title; + const itemUrl = frontmatter.url; + const normalizedItemUrl = this.normalizeUrl(itemUrl); + + 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 (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: 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, + projectNumber: project.number, + projectUrl: project.url, + fullProjectData: fullProjectData + }; + items.push(item); + } + } catch (error) { + console.error(`Error processing file ${file.path}:`, error); + } + } + + + 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 { + // Strip surrounding quotes from string values + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + 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"); + + // Header: Number and type + const headerEl = itemEl.createEl("div", { cls: "github-kanban-item-header" }); + + const type = item.pull_request ? "PR" : "Issue"; + const number = item.number; + 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", { 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); + + // 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" + }); + } + } + + // 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 }); + } + + // 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) + '...'; + } + + descEl.setText(bodyPreview); + } + + // Make clickable to open the file or GitHub URL + itemEl.onclick = () => this.openItemFile(item); + } + + private async openItemFile(item: any): Promise { + const files = this.app.vault.getMarkdownFiles(); + + // 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 + } + } + } + + // 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 b69f612..dce2db9 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; @@ -29,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"); @@ -42,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..."); @@ -177,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(); @@ -222,10 +334,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(); @@ -281,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 7478b54..4564579 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,41 @@ 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 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 tracked projects from global settings + if (projectData && trackedProjectIds.length > 0) { + projectData = projectData.filter(p => + trackedProjectIds.includes(p.projectId) + ); + } + await this.createOrUpdatePullRequestFile( effectiveRepo, ownerCleaned, repoCleaned, pr, + projectData, ); } } @@ -74,6 +102,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 +143,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 +176,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..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"; @@ -570,9 +570,137 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { }) ); + // GitHub Projects Section + 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) and create notes for project items. Project fields are available as template variables.", + }) + .addClass("setting-item-description"); + + const projectSettingsContainer = projectsContainer.createDiv( + "github-issues-settings-group", + ); + + // 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"); + + const availableProjectsContent = projectSettingsContainer.createDiv( + "github-issues-tab-content", + ); + + // Tracked Projects content + const projectListContainer = trackedProjectsContent.createDiv( + "github-issues-project-list", + ); + + 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(); + } + ); + + // Available Projects content + const loadProjectsButtonContainer = availableProjectsContent.createDiv( + "github-issues-load-repos-container", + ); + + const projectsLoadDescription = loadProjectsButtonContainer.createEl("p", { + text: "Load your GitHub Projects to add them to tracking.", + cls: "github-issues-load-description", + }); + + 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 projectsResultsContainer = availableProjectsContent.createDiv( + "github-issues-repos-results-container", + ); + projectsResultsContainer.addClass("github-issues-hidden"); + + loadProjectsButton.onclick = async () => { + loadProjectsButton.disabled = true; + const buttonText = loadProjectsButton.querySelector("span:last-child"); + if (buttonText) { + buttonText.textContent = "Loading..."; + } + + try { + 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; + if (buttonText) { + buttonText.textContent = "Load Projects"; + } + } + }; + + // Tab switching + trackedProjectsTab.onclick = () => { + trackedProjectsTab.addClass("mod-cta"); + availableProjectsTab.removeClass("mod-cta"); + trackedProjectsContent.addClass("active"); + availableProjectsContent.removeClass("active"); + }; + + 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( @@ -1079,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( @@ -1685,4 +1813,559 @@ export class GitHubTrackerSettingTab extends PluginSettingTab { errorBadge.setText("Error validating token"); } } + + /** + * 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); + + // 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, + number: project.number, + 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, + }); + } + + 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}`); + } + } + + /** + * 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 bea7c7a..755a5bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,87 @@ 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 +} + +// 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; + 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 +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 +149,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 +184,8 @@ export const DEFAULT_SETTINGS: GitHubTrackerSettings = { backgroundSyncInterval: 30, cleanupClosedIssuesDays: 30, globalDefaults: DEFAULT_GLOBAL_DEFAULTS, + enableProjectTracking: true, + trackedProjects: [], }; // Default repository tracking settings 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/src/util/templateUtils.ts b/src/util/templateUtils.ts index 4bd8a77..1f79c71 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 @@ -249,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 @@ -281,6 +356,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 +370,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 +383,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 +414,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 +423,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 +436,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 +473,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..7ba7597 100644 --- a/styles.css +++ b/styles.css @@ -1761,3 +1761,558 @@ 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: 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.4em; + font-weight: 700; + color: var(--text-normal); + display: flex; + align-items: center; + gap: 8px; +} + +.github-kanban-refresh-btn { + 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); + 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-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); + padding: 60px 40px; + background: var(--background-secondary); + border-radius: 8px; + border: 1px dashed var(--background-modifier-border); +} + +/* Project Board */ +.github-kanban-project { + margin-bottom: 24px; +} + +.github-kanban-project h3 { + margin: 0 0 16px 0; + font-size: 1.1em; + font-weight: 600; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 8px; +} + +.github-kanban-board { + display: flex; + gap: 16px; + overflow-x: auto; + 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 300px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + 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; + font-size: 0.9em; + font-weight: 600; + color: var(--text-normal); + 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: 10px; + padding: 12px; + overflow-y: auto; + flex: 1; +} + +/* Cards */ +.github-kanban-item { + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 14px; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.github-kanban-item:hover { + 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: 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: 6px; +} + +.github-kanban-item-meta svg { + width: 12px; + height: 12px; + opacity: 0.7; +} + +.github-kanban-item-labels { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 10px; +} + +.github-kanban-label { + 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.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-secondary); +} + +.theme-dark .github-kanban-column h4 { + 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: 12px; + } + + .github-kanban-column { + flex: none; + width: 100%; + 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; +} 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}*