diff --git a/src/filesystem.ts b/src/filesystem.ts index 0c43fbd..4efa5fc 100644 --- a/src/filesystem.ts +++ b/src/filesystem.ts @@ -1,9 +1,9 @@ -import { Issue } from "./issue"; import {Vault, TFile, TAbstractFile, TFolder} from "obsidian"; import { GitlabIssuesSettings } from "./settings"; import log from "./logger"; import { compile } from 'handlebars'; import defaultTemplate from './default-template'; +import {ObsidianIssue} from "./types"; export default class Filesystem { @@ -39,30 +39,30 @@ export default class Filesystem { } } - public processIssues(issues: Array) + public processIssues(issues: Array) { this.vault.adapter.read(this.settings.templateFile) .then((rawTemplate: string) => { issues.map( - (issue: Issue) => this.writeFile(issue, compile(rawTemplate)) + (issue: ObsidianIssue) => this.writeFile(issue, compile(rawTemplate)) ); }) .catch((error) => { issues.map( - (issue: Issue) => this.writeFile(issue, compile(defaultTemplate.toString())) + (issue: ObsidianIssue) => this.writeFile(issue, compile(defaultTemplate.toString())) ); }) ; } - private writeFile(issue: Issue, template: HandlebarsTemplateDelegate) + private writeFile(issue: ObsidianIssue, template: HandlebarsTemplateDelegate) { this.vault.create(this.buildFileName(issue), template(issue)) .catch((error) => log(error.message)) ; } - private buildFileName(issue: Issue): string + private buildFileName(issue: ObsidianIssue): string { return this.settings.outputDir + '/' + issue.filename + '.md'; } diff --git a/src/gitlab-loader.ts b/src/gitlab-loader.ts index 92ee487..8df4cb1 100644 --- a/src/gitlab-loader.ts +++ b/src/gitlab-loader.ts @@ -1,8 +1,9 @@ import GitlabApi from "./gitlab-api"; -import {GitlabIssue, Issue} from "./issue"; +import {GitlabIssue} from "./issue"; import {App} from "obsidian"; import {GitlabIssuesSettings} from "./settings"; import Filesystem from "./filesystem"; +import {Issue} from "./types"; export default class GitlabLoader { @@ -34,7 +35,6 @@ export default class GitlabLoader { if(this.settings.purgeIssues) { this.fs.purgeExistingIssues(); } - this.fs.processIssues(gitlabIssues); }) .catch(error => { diff --git a/src/issue.ts b/src/issue.ts index 7e26b30..9261592 100644 --- a/src/issue.ts +++ b/src/issue.ts @@ -1,34 +1,53 @@ -import { sanitizeFileName } from './util'; +import {sanitizeFileName} from './util'; +import {Assignee, Epic, Issue, ObsidianIssue, References, ShortIssue, TimeStats} from "./types"; -export interface Issue { - readonly id: number; - readonly title: string; - readonly description: string; - readonly due_date: string; - readonly web_url: string; - readonly references: string; - readonly filename: string; -} - -export class GitlabIssue implements Issue { +export class GitlabIssue implements ObsidianIssue { id: number; title: string; description: string; due_date: string; web_url: string; - references: string; + references: string | References; get filename() { return sanitizeFileName(this.title); } constructor(issue: Issue) { - this.id = issue.id; - this.title = issue.title; - this.description = issue.description; - this.due_date = issue.due_date; - this.web_url = issue.web_url; - this.references = issue.references; + Object.assign(this, issue); } + + _links: { + self: string; + notes: string; + award_emoji: string; + project: string; + closed_as_duplicate_of: string + }; + assignees: Assignee[]; + author: Assignee; + closed_by: Assignee; + confidential: boolean; + created_at: string; + discussion_locked: boolean; + downvotes: number; + epic: Epic; + has_tasks: boolean; + iid: number; + imported: boolean; + imported_from: string; + issue_type: string; + labels: string[]; + merge_requests_count: number; + milestone: ShortIssue; + project_id: number; + severity: string; + state: string; + task_completion_status: { count: number; completed_count: number }; + task_status: string; + time_stats: TimeStats; + updated_at: string; + upvotes: number; + user_notes_count: number; } diff --git a/src/main.ts b/src/main.ts index b2c7737..456e676 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import {Notice, Plugin, addIcon} from 'obsidian'; +import {addIcon, Notice, Plugin} from 'obsidian'; import {DEFAULT_SETTINGS, GitlabIssuesSettings, GitlabIssuesSettingTab} from './settings'; import log from "./logger"; import Filesystem from "./filesystem"; @@ -7,8 +7,8 @@ import gitlabIcon from './assets/gitlab-icon.svg'; export default class GitlabIssuesPlugin extends Plugin { settings: GitlabIssuesSettings; - startupTimeout: number|null = null; - automaticRefresh: number|null = null; + startupTimeout: number | null = null; + automaticRefresh: number | null = null; iconAdded = false; async onload() { @@ -17,6 +17,7 @@ export default class GitlabIssuesPlugin extends Plugin { await this.loadSettings(); this.addSettingTab(new GitlabIssuesSettingTab(this.app, this)); + if (this.settings.gitlabToken) { this.createOutputFolder(); this.addIconToLeftRibbon(); @@ -26,12 +27,35 @@ export default class GitlabIssuesPlugin extends Plugin { } } + scheduleAutomaticRefresh() { + if (this.automaticRefresh) { + window.clearInterval(this.automaticRefresh); + } + if (this.settings.intervalOfRefresh !== "off") { + const intervalMinutes = parseInt(this.settings.intervalOfRefresh); + + this.automaticRefresh = this.registerInterval(window.setInterval(() => { + this.fetchFromGitlab(); + }, intervalMinutes * 60 * 1000)); // every settings interval in minutes + } + } + + onunload() { + + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } + private addIconToLeftRibbon() { - if (this.settings.showIcon) - { + if (this.settings.showIcon) { // Ensure we did not already add an icon - if (!this.iconAdded) - { + if (!this.iconAdded) { addIcon("gitlab", gitlabIcon); this.addRibbonIcon('gitlab', 'Gitlab Issues', (evt: MouseEvent) => { this.fetchFromGitlab(); @@ -56,18 +80,11 @@ export default class GitlabIssuesPlugin extends Plugin { if (this.startupTimeout) { window.clearTimeout(this.startupTimeout); } - this.startupTimeout = this.registerInterval(window.setTimeout(() => { - this.fetchFromGitlab(); - }, 30 * 1000)); // after 30 seconds - } - - private scheduleAutomaticRefresh() { - if (this.automaticRefresh) { - window.clearInterval(this.automaticRefresh); + if(this.settings.refreshOnStartup) { + this.startupTimeout = this.registerInterval(window.setTimeout(() => { + this.fetchFromGitlab(); + }, 30 * 1000)); // after 30 seconds } - this.automaticRefresh = this.registerInterval(window.setInterval(() => { - this.fetchFromGitlab(); - }, 15 * 60 * 1000)); // every 15 minutes } private createOutputFolder() { @@ -75,21 +92,9 @@ export default class GitlabIssuesPlugin extends Plugin { fs.createOutputDirectory(); } - private fetchFromGitlab () { + private fetchFromGitlab() { new Notice('Updating issues from Gitlab'); const loader = new GitlabLoader(this.app, this.settings); loader.loadIssues(); } - - onunload() { - - } - - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - async saveSettings() { - await this.saveData(this.settings); - } } diff --git a/src/settings.ts b/src/settings.ts index 4257198..cbd8b79 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,7 +1,9 @@ import {App, normalizePath, PluginSettingTab, Setting} from "obsidian"; import GitlabIssuesPlugin from "./main"; -type GitlabIssuesLevel = 'personal' | 'project' | 'group' + +type GitlabIssuesLevel = 'personal' | 'project' | 'group'; +type GitlabRefreshInterval = "15" | "30" | "45" |"60" | "120" | "off"; export interface GitlabIssuesSettings { gitlabUrl: string; @@ -13,6 +15,8 @@ export interface GitlabIssuesSettings { filter: string; showIcon: boolean; purgeIssues: boolean; + refreshOnStartup: boolean; + intervalOfRefresh: GitlabRefreshInterval; gitlabApiUrl(): string; } @@ -26,6 +30,8 @@ export const DEFAULT_SETTINGS: GitlabIssuesSettings = { filter: 'due_date=month', showIcon: false, purgeIssues: true, + refreshOnStartup: true, + intervalOfRefresh: "15", gitlabApiUrl(): string { return `${this.gitlabUrl}/api/v4`; } @@ -102,6 +108,19 @@ export class GitlabIssuesSettingTab extends PluginSettingTab { })); + new Setting(containerEl) + + .setName('Refresh Rate') + .setDesc("That rate at which gitlab issues will be pulled.") + .addDropdown(value => value + .addOptions({off: "off", "15": "15", "30": "30", "45": "45", "60": "60", "120": "120"}) + .setValue(this.plugin.settings.intervalOfRefresh) + .onChange(async (value: GitlabRefreshInterval) => { + this.plugin.settings.intervalOfRefresh = value; + this.plugin.scheduleAutomaticRefresh(); + await this.plugin.saveSettings(); + })); + new Setting(containerEl) .setName('GitLab Level') .addDropdown(value => value @@ -110,18 +129,32 @@ export class GitlabIssuesSettingTab extends PluginSettingTab { .onChange(async (value: GitlabIssuesLevel) => { this.plugin.settings.gitlabIssuesLevel = value; await this.plugin.saveSettings(); + this.display(); })); - new Setting(containerEl) - .setName('Set Gitlab Project/Group Id') - .setDesc('If Group or Project is set, add the corresponding ID.') - .addText(value => value - .setValue(this.plugin.settings.gitlabAppId) - .onChange(async (value: string) => { - this.plugin.settings.gitlabAppId = value; - await this.plugin.saveSettings(); - })); - + if(this.plugin.settings.gitlabIssuesLevel !== "personal") { + const gitlabIssuesLevelIdObject = this.plugin.settings.gitlabIssuesLevel === 'group' + ? {text: "Group", url: "https://docs.gitlab.com/ee/user/group/#get-the-group-id"} + : {text: "Project", url: "https://docs.gitlab.com/ee/user/project/working_with_projects.html#access-the-project-overview-page-by-using-the-project-id"}; + + const descriptionDocumentFragment = document.createDocumentFragment(); + const descriptionLinkElement = descriptionDocumentFragment.createEl('a', { + href: gitlabIssuesLevelIdObject.url, + text: `Find your ${gitlabIssuesLevelIdObject.text} Id.`, + title: `Goto ${gitlabIssuesLevelIdObject.url}` + }); + descriptionDocumentFragment.appendChild(descriptionLinkElement); + + new Setting(containerEl) + .setName(`Set Gitlab ${gitlabIssuesLevelIdObject.text} Id`) + .setDesc(descriptionDocumentFragment) + .addText(value => value + .setValue(this.plugin.settings.gitlabAppId) + .onChange(async (value: string) => { + this.plugin.settings.gitlabAppId = value; + await this.plugin.saveSettings(); + })); + } new Setting(containerEl) .setName('Purge issues that are no longer in Gitlab?') .addToggle(value => value @@ -139,7 +172,14 @@ export class GitlabIssuesSettingTab extends PluginSettingTab { this.plugin.settings.showIcon = value; await this.plugin.saveSettings(); })); - + new Setting(containerEl) + .setName('Should refresh Gitlab issues on Startup?') + .addToggle(value => value + .setValue(this.plugin.settings.refreshOnStartup) + .onChange(async (value) => { + this.plugin.settings.refreshOnStartup = value; + await this.plugin.saveSettings(); + })); containerEl.createEl('h3', {text: 'More Information'}); containerEl.createEl('a', { text: 'View the Gitlab documentation', diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b67cea3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,82 @@ +export interface Assignee { + readonly avatar_url: string; + readonly id: number; + readonly locked: boolean; + readonly name: string; + readonly state: string; + readonly username: string; + readonly web_url: string; +} + +export interface Epic { + readonly id: number, + readonly iid: number, + readonly title: string, + readonly url: string, + readonly group_id: number +} + +export interface References { + readonly short: string, + readonly relative: string, + readonly full: string +} + +export interface TimeStats { + readonly time_estimate: number; + readonly total_time_spent: number; + readonly human_time_spent: number; + readonly human_total_time_spent: number; +} + +export interface ShortIssue { + readonly due_date: string, + readonly project_id: number, + readonly state: string, + readonly description: string, + readonly iid: number, + readonly id: number, + readonly title: string, + readonly created_at: string, + readonly updated_at: string +} + +export interface Issue extends ShortIssue { + readonly web_url: string; + readonly references: string | References; + + readonly assignees: Assignee[]; + readonly author: Assignee; + readonly closed_by: Assignee; + readonly epic: Epic; + readonly labels: string[]; + readonly upvotes: number; + readonly downvotes: number; + readonly merge_requests_count: number; + readonly user_notes_count: number; + readonly imported: boolean; + readonly imported_from: string; + readonly has_tasks: boolean + readonly task_status: string, + readonly confidential: boolean, + readonly discussion_locked: boolean + readonly issue_type: string, + readonly time_stats: TimeStats, + readonly severity: string, + readonly _links: { + self: string, + notes: string, + award_emoji: string, + project: string, + closed_as_duplicate_of: string + }, + readonly task_completion_status: { + count: number, + completed_count: number + } + readonly milestone: ShortIssue +} + +export interface ObsidianIssue extends Issue { + filename: string +} diff --git a/tests/issue.test.ts b/tests/issue.test.ts new file mode 100644 index 0000000..03411de --- /dev/null +++ b/tests/issue.test.ts @@ -0,0 +1,94 @@ + +import * as Utils from '../src/util'; +import { Issue } from '../src/types'; +import {GitlabIssue} from "../src/issue"; + + + +const mockIssue: Issue = { + id: 1, + title: 'Test Issue', + description: 'This is a test issue', + due_date: '2024-12-31', + web_url: 'https://gitlab.com/test/test-issue', + references: 'test-ref', + + _links: { + self: 'self-link', + notes: 'notes-link', + award_emoji: 'award-emoji-link', + project: 'project-link', + closed_as_duplicate_of: 'closed-duplicate-link' + }, + assignees: [], + author: { id: 1, name: 'author', username: 'author', state: 'active', avatar_url: '', web_url: '', locked: false }, + closed_by: { id: 2, name: 'closer', username: 'closer', state: 'active', avatar_url: '', web_url: '', locked: false }, + confidential: false, + created_at: '2024-01-01', + discussion_locked: false, + downvotes: 0, + epic: { id: 1, iid: 1, title: 'Epic', group_id: 9, url: "" }, + has_tasks: false, + iid: 1, + imported: false, + imported_from: '', + issue_type: 'issue', + labels: ['bug'], + merge_requests_count: 0, + milestone: { id: 1, iid: 1, title: 'Milestone', updated_at: '', created_at: "", description: "", due_date: "", project_id:8, state:"" }, + project_id: 1, + severity: 'low', + state: 'opened', + task_completion_status: { count: 0, completed_count: 0 }, + task_status: 'open', + time_stats: { time_estimate: 0, total_time_spent: 0, human_time_spent: 7, human_total_time_spent: 8 }, + updated_at: '2024-01-02', + upvotes: 1, + user_notes_count: 0 +}; + +describe('GitlabIssue', () => { + it('should correctly assign properties from the issue object', () => { + const gitlabIssue = new GitlabIssue(mockIssue); + + expect(gitlabIssue.id).toEqual(mockIssue.id); + expect(gitlabIssue.title).toEqual(mockIssue.title); + expect(gitlabIssue.description).toEqual(mockIssue.description); + expect(gitlabIssue.due_date).toEqual(mockIssue.due_date); + expect(gitlabIssue.web_url).toEqual(mockIssue.web_url); + expect(gitlabIssue.references).toEqual(mockIssue.references); + expect(gitlabIssue._links).toEqual(mockIssue._links); + expect(gitlabIssue.assignees).toEqual(mockIssue.assignees); + expect(gitlabIssue.author).toEqual(mockIssue.author); + expect(gitlabIssue.closed_by).toEqual(mockIssue.closed_by); + expect(gitlabIssue.confidential).toEqual(mockIssue.confidential); + expect(gitlabIssue.created_at).toEqual(mockIssue.created_at); + expect(gitlabIssue.discussion_locked).toEqual(mockIssue.discussion_locked); + expect(gitlabIssue.downvotes).toEqual(mockIssue.downvotes); + expect(gitlabIssue.epic).toEqual(mockIssue.epic); + expect(gitlabIssue.has_tasks).toEqual(mockIssue.has_tasks); + expect(gitlabIssue.iid).toEqual(mockIssue.iid); + expect(gitlabIssue.imported).toEqual(mockIssue.imported); + expect(gitlabIssue.imported_from).toEqual(mockIssue.imported_from); + expect(gitlabIssue.issue_type).toEqual(mockIssue.issue_type); + expect(gitlabIssue.labels).toEqual(mockIssue.labels); + expect(gitlabIssue.merge_requests_count).toEqual(mockIssue.merge_requests_count); + expect(gitlabIssue.milestone).toEqual(mockIssue.milestone); + expect(gitlabIssue.project_id).toEqual(mockIssue.project_id); + expect(gitlabIssue.severity).toEqual(mockIssue.severity); + expect(gitlabIssue.state).toEqual(mockIssue.state); + expect(gitlabIssue.task_completion_status).toEqual(mockIssue.task_completion_status); + expect(gitlabIssue.task_status).toEqual(mockIssue.task_status); + expect(gitlabIssue.time_stats).toEqual(mockIssue.time_stats); + expect(gitlabIssue.updated_at).toEqual(mockIssue.updated_at); + expect(gitlabIssue.upvotes).toEqual(mockIssue.upvotes); + expect(gitlabIssue.user_notes_count).toEqual(mockIssue.user_notes_count); + }); + + it('should correctly sanitize the filename using the title', () => { + const mockSanitizeFileName = jest.spyOn(Utils, "sanitizeFileName").mockReturnValue('sanitized-Test'); + const gitlabIssue = new GitlabIssue(mockIssue); + expect(gitlabIssue.filename).toEqual(`sanitized-Test`); + expect(mockSanitizeFileName).toHaveBeenCalledWith(mockIssue.title); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..938b621 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,14 @@ +import logMessage from '../src/logger'; + +describe('logger', () => { + it('should log the message with the correct prefix', () => { + const message = 'This is a test message'; + const expectedOutput = 'Gitlab Issues: This is a test message'; + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + logMessage(message); + + expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput); + consoleLogSpy.mockRestore(); + }); +});