diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..ce7c79584 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Verwendet IntelliSense zum Ermitteln möglicher Attribute. + // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. + // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:4200", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/src/app/features/issue/issue-service-interface.ts b/src/app/features/issue/issue-service-interface.ts index 44391e31b..b2ce7e1ee 100644 --- a/src/app/features/issue/issue-service-interface.ts +++ b/src/app/features/issue/issue-service-interface.ts @@ -38,7 +38,7 @@ export interface IssueServiceInterface { { task: Task; taskChanges: Partial; - issue: IssueData; + issue: IssueData | null; }[] >; diff --git a/src/app/features/issue/providers/gitlab/gitlab-api/gitlab-api.service.ts b/src/app/features/issue/providers/gitlab/gitlab-api/gitlab-api.service.ts index 85591e580..f1572bcb9 100644 --- a/src/app/features/issue/providers/gitlab/gitlab-api/gitlab-api.service.ts +++ b/src/app/features/issue/providers/gitlab/gitlab-api/gitlab-api.service.ts @@ -29,36 +29,33 @@ import { GITLAB_TYPE, ISSUE_PROVIDER_HUMANIZED } from '../../../issue.const'; export class GitlabApiService { constructor(private _snackService: SnackService, private _http: HttpClient) {} - getById$(id: number, cfg: GitlabCfg): Observable { + getById$(id: string, cfg: GitlabCfg): Observable { return this._sendRequest$( { - url: `${this.apiLink(cfg)}/issues/${id}`, + url: `${this.apiLink(cfg, id)}`, }, cfg, ).pipe( mergeMap((issue: GitlabOriginalIssue) => { - return this.getIssueWithComments$(mapGitlabIssue(issue), cfg); + return this.getIssueWithComments$(mapGitlabIssue(issue, cfg), cfg); }), ); } - getByIds$(ids: string[], cfg: GitlabCfg): Observable { - let queryParams = 'iids[]='; - for (let i = 0; i < ids.length; i++) { - if (i === ids.length - 1) { - queryParams += ids[i]; - } else { - queryParams += `${ids[i]}&iids[]=`; - } - } + getByIds$(project: string, ids: string[], cfg: GitlabCfg): Observable { + const queryParams = 'iids[]=' + ids.join('&iids[]='); + return this._sendRequest$( { - url: `${this.apiLink(cfg)}/issues?${queryParams}&per_page=100`, + url: `${this.apiLink( + cfg, + null, + )}/projects/${project}/issues?${queryParams}&scope=${cfg.scope}&per_page=100`, }, cfg, ).pipe( map((issues: GitlabOriginalIssue[]) => { - return issues ? issues.map(mapGitlabIssue) : []; + return issues ? issues.map((issue) => mapGitlabIssue(issue, cfg)) : []; }), mergeMap((issues: GitlabIssue[]) => { if (issues && issues.length) { @@ -93,12 +90,14 @@ export class GitlabApiService { } return this._sendRequest$( { - url: `${this.apiLink(cfg)}/issues?search=${searchText}&order_by=updated_at`, + url: `${this.apiLink(cfg, null)}/issues?search=${searchText}&scope=${ + cfg.scope + }&order_by=updated_at`, }, cfg, ).pipe( map((issues: GitlabOriginalIssue[]) => { - return issues ? issues.map(mapGitlabIssue) : []; + return issues ? issues.map((issue) => mapGitlabIssue(issue, cfg)) : []; }), mergeMap((issues: GitlabIssue[]) => { if (issues && issues.length) { @@ -137,19 +136,68 @@ export class GitlabApiService { { url: `${this.apiLink( cfg, - )}/issues?state=opened&order_by=updated_at&per_page=100&page=${pageNumber}`, + null, + )}/issues?state=opened&order_by=updated_at&per_page=100&scope=${ + cfg.scope + }&page=${pageNumber}`, }, cfg, ).pipe( take(1), map((issues: GitlabOriginalIssue[]) => { - return issues ? issues.map(mapGitlabIssue) : []; + return issues ? issues.map((issue) => mapGitlabIssue(issue, cfg)) : []; }), ); } + getFullIssueRef$(issue: string | number, projectConfig: GitlabCfg): string { + if (this._getPartsFromIssue$(issue).length === 2) { + return issue.toString(); + } else { + return ( + this.getProjectFromIssue$(issue, projectConfig) + + '#' + + this._getIidFromIssue$(issue) + ); + } + } + + getProjectFromIssue$(issue: string | number | null, projectConfig: GitlabCfg): string { + const parts: string[] = this._getPartsFromIssue$(issue); + if (parts.length === 2) { + return parts[0]; + } + + const projectURL: string = projectConfig.project ? projectConfig.project : ''; + + const projectPath = projectURL.match(GITLAB_PROJECT_REGEX); + if (!projectPath) { + throwError('Gitlab Project URL'); + } + return projectURL; + } + + private _getIidFromIssue$(issue: string | number): string { + const parts: string[] = this._getPartsFromIssue$(issue); + if (parts.length === 2) { + return parts[1]; + } else { + return parts[0]; + } + } + + private _getPartsFromIssue$(issue: string | number | null): string[] { + if (typeof issue === 'string') { + return issue.split('#'); + } else if (typeof issue == 'number') { + return [issue.toString()]; + } else { + return []; + } + } + private _getIssueComments$( - issueid: number, + issueid: number | string, pageNumber: number, cfg: GitlabCfg, ): Observable { @@ -158,9 +206,7 @@ export class GitlabApiService { } return this._sendRequest$( { - url: `${this.apiLink( - cfg, - )}/issues/${issueid}/notes?per_page=100&page=${pageNumber}`, + url: `${this.apiLink(cfg, issueid)}/notes?per_page=100&page=${pageNumber}`, }, cfg, ).pipe( @@ -259,25 +305,36 @@ export class GitlabApiService { return throwError({ [HANDLED_ERROR_PROP_STR]: 'Gitlab: Api request failed.' }); } - private apiLink(projectConfig: GitlabCfg): string { + private apiLink(projectConfig: GitlabCfg, issueId: string | number | null): string { let apiURL: string = ''; - let projectURL: string = projectConfig.project ? projectConfig.project : ''; + if (projectConfig.gitlabBaseUrl) { const fixedUrl = projectConfig.gitlabBaseUrl.match(/.*\/$/) ? projectConfig.gitlabBaseUrl : `${projectConfig.gitlabBaseUrl}/`; - apiURL = fixedUrl + 'api/v4/projects/'; + apiURL = fixedUrl + 'api/v4/'; } else { apiURL = GITLAB_API_BASE_URL + '/'; } - const projectPath = projectURL.match(GITLAB_PROJECT_REGEX); - if (projectPath) { - projectURL = projectURL.replace(/\//gi, '%2F'); + + const projectURL: string = this.getProjectFromIssue$(issueId, projectConfig).replace( + /\//gi, + '%2F', + ); + + if (issueId) { + apiURL += 'projects/' + projectURL + '/issues/' + this._getIidFromIssue$(issueId); } else { - // Should never enter here - throwError('Gitlab Project URL'); + switch (projectConfig.source) { + case 'project': + apiURL += 'projects/' + projectURL; + break; + case 'group': + apiURL += 'groups/' + projectURL; + break; + } } - apiURL += projectURL; + return apiURL; } } diff --git a/src/app/features/issue/providers/gitlab/gitlab-common-interfaces.service.ts b/src/app/features/issue/providers/gitlab/gitlab-common-interfaces.service.ts index 42724270f..1d2306fbf 100644 --- a/src/app/features/issue/providers/gitlab/gitlab-common-interfaces.service.ts +++ b/src/app/features/issue/providers/gitlab/gitlab-common-interfaces.service.ts @@ -43,25 +43,23 @@ export class GitlabCommonInterfacesService implements IssueServiceInterface { return isGitlabEnabled(cfg); } - issueLink$(issueId: number, projectId: string): Observable { + issueLink$(issueId: string, projectId: string): Observable { return this._getCfgOnce$(projectId).pipe( map((cfg) => { + const project: string = this._gitlabApiService.getProjectFromIssue$(issueId, cfg); if (cfg.gitlabBaseUrl) { const fixedUrl = cfg.gitlabBaseUrl.match(/.*\/$/) ? cfg.gitlabBaseUrl : `${cfg.gitlabBaseUrl}/`; - return `${fixedUrl}${cfg.project}/issues/${issueId}`; + return `${fixedUrl}${project}/issues/${issueId}`; } else { - return `${GITLAB_BASE_URL}${cfg.project?.replace( - /%2F/g, - '/', - )}/issues/${issueId}`; + return `${GITLAB_BASE_URL}${project}/issues/${issueId}`; } }), ); } - getById$(issueId: number, projectId: string): Observable { + getById$(issueId: string, projectId: string): Observable { return this._getCfgOnce$(projectId).pipe( concatMap((gitlabCfg) => this._gitlabApiService.getById$(issueId, gitlabCfg)), ); @@ -92,7 +90,9 @@ export class GitlabCommonInterfacesService implements IssueServiceInterface { } const cfg = await this._getCfgOnce$(task.projectId).toPromise(); - const issue = await this._gitlabApiService.getById$(+task.issueId, cfg).toPromise(); + const fullIssueRef = this._gitlabApiService.getFullIssueRef$(task.issueId, cfg); + const idFormatChanged = task.issueId !== fullIssueRef; + const issue = await this._gitlabApiService.getById$(fullIssueRef, cfg).toPromise(); const issueUpdate: number = new Date(issue.updated_at).getTime(); const commentsByOthers = @@ -111,14 +111,14 @@ export class GitlabCommonInterfacesService implements IssueServiceInterface { const wasUpdated = lastRemoteUpdate > (task.issueLastUpdated || 0); - if (wasUpdated) { + if (wasUpdated || idFormatChanged) { return { taskChanges: { ...this.getAddTaskData(issue), issueWasUpdated: true, }, issue, - issueTitle: this._formatIssueTitleForSnack(issue.number, issue.title), + issueTitle: this._formatIssueTitleForSnack(issue), }; } return null; @@ -126,61 +126,89 @@ export class GitlabCommonInterfacesService implements IssueServiceInterface { async getFreshDataForIssueTasks( tasks: Task[], - ): Promise<{ task: Task; taskChanges: Partial; issue: GitlabIssue }[]> { - // First sort the tasks by the issueId - // because the API returns it in a desc order by issue iid(issueId) - // so it makes the update check easier and faster - tasks.sort((a, b) => +(b.issueId as string) - +(a.issueId as string)); + ): Promise<{ task: Task; taskChanges: Partial; issue: GitlabIssue | null }[]> { const projectId = tasks && tasks[0].projectId ? tasks[0].projectId : 0; if (!projectId) { throw new Error('No projectId'); } const cfg = await this._getCfgOnce$(projectId).toPromise(); - const issues: GitlabIssue[] = []; + const issues = new Map(); const paramsCount = 59; // Can't send more than 59 issue id For some reason it returns 502 bad gateway - let ids; + const iidsByProject = new Map(); let i = 0; - while (i < tasks.length) { - ids = []; - for (let j = 0; j < paramsCount && i < tasks.length; j++, i++) { - ids.push(tasks[i].issueId); + + for (const task of tasks) { + if (!task.issueId) { + continue; } - issues.push( - ...(await this._gitlabApiService.getByIds$(ids as string[], cfg).toPromise()), - ); + const project = this._gitlabApiService.getProjectFromIssue$(task.issueId, cfg); + if (!iidsByProject.has(project)) { + iidsByProject.set(project, []); + } + iidsByProject.get(project)?.push(task.issueId as string); } + iidsByProject.forEach(async (allIds, project) => { + for (i = 0; i < allIds.length; i += paramsCount) { + ( + await this._gitlabApiService + .getByIds$(project, allIds.slice(i, i + paramsCount), cfg) + .toPromise() + ).forEach((found) => { + issues.set(found.id as string, found); + }); + } + }); + const updatedIssues: { task: Task; taskChanges: Partial; - issue: GitlabIssue; + issue: GitlabIssue | null; }[] = []; - for (i = 0; i < tasks.length; i++) { - const issueUpdate: number = new Date(issues[i].updated_at).getTime(); - const commentsByOthers = - cfg.filterUsername && cfg.filterUsername.length > 1 - ? issues[i].comments.filter( - (comment) => comment.author.username !== cfg.filterUsername, - ) - : issues[i].comments; + for (const task of tasks) { + if (!task.issueId) { + continue; + } + let idFormatChanged = false; + const fullIssueRef = this._gitlabApiService.getFullIssueRef$(task.issueId, cfg); + idFormatChanged = task.issueId !== fullIssueRef; + const issue = issues.get(fullIssueRef); + if (issue) { + const issueUpdate: number = new Date(issue.updated_at).getTime(); + const commentsByOthers = + cfg.filterUsername && cfg.filterUsername.length > 1 + ? issue.comments.filter( + (comment) => comment.author.username !== cfg.filterUsername, + ) + : issue.comments; - const updates: number[] = [ - ...commentsByOthers.map((comment) => new Date(comment.created_at).getTime()), - issueUpdate, - ].sort(); - const lastRemoteUpdate = updates[updates.length - 1]; - const wasUpdated = lastRemoteUpdate > (tasks[i].issueLastUpdated || 0); - - if (wasUpdated) { + const updates: number[] = [ + ...commentsByOthers.map((comment) => new Date(comment.created_at).getTime()), + issueUpdate, + ].sort(); + const lastRemoteUpdate = updates[updates.length - 1]; + const wasUpdated = lastRemoteUpdate > (tasks[i].issueLastUpdated || 0); + if (wasUpdated || idFormatChanged) { + updatedIssues.push({ + task, + taskChanges: { + ...this.getAddTaskData(issue), + issueWasUpdated: true, + }, + issue, + }); + } + } else { updatedIssues.push({ - task: tasks[i], + task, taskChanges: { - ...this.getAddTaskData(issues[i]), issueWasUpdated: true, + issueId: null, + issueType: null, }, - issue: issues[i], + issue: null, }); } } @@ -189,10 +217,11 @@ export class GitlabCommonInterfacesService implements IssueServiceInterface { getAddTaskData(issue: GitlabIssue): Partial & { title: string } { return { - title: this._formatIssueTitle(issue.number, issue.title), + title: this._formatIssueTitle(issue), issuePoints: issue.weight, issueWasUpdated: false, issueLastUpdated: new Date(issue.updated_at).getTime(), + issueId: issue.id as string, }; } @@ -204,12 +233,12 @@ export class GitlabCommonInterfacesService implements IssueServiceInterface { return await this._gitlabApiService.getProjectIssues$(1, cfg).toPromise(); } - private _formatIssueTitle(id: number, title: string): string { - return `#${id} ${title}`; + private _formatIssueTitle(issue: GitlabIssue): string { + return `#${issue.number} ${issue.title}`; } - private _formatIssueTitleForSnack(id: number, title: string): string { - return `${truncate(this._formatIssueTitle(id, title))}`; + private _formatIssueTitleForSnack(issue: GitlabIssue): string { + return `${truncate(this._formatIssueTitle(issue))}`; } private _getCfgOnce$(projectId: string): Observable { diff --git a/src/app/features/issue/providers/gitlab/gitlab-issue/gitlab-issue-map.util.ts b/src/app/features/issue/providers/gitlab/gitlab-issue/gitlab-issue-map.util.ts index 1398ccd62..b2cf3fbbc 100644 --- a/src/app/features/issue/providers/gitlab/gitlab-issue/gitlab-issue-map.util.ts +++ b/src/app/features/issue/providers/gitlab/gitlab-issue/gitlab-issue-map.util.ts @@ -1,8 +1,12 @@ import { GitlabIssue } from './gitlab-issue.model'; import { GitlabOriginalIssue } from '../gitlab-api/gitlab-api-responses'; import { IssueProviderKey, SearchResultItem } from '../../../issue.model'; +import { GitlabCfg } from '../gitlab'; -export const mapGitlabIssue = (issue: GitlabOriginalIssue): GitlabIssue => { +export const mapGitlabIssue = ( + issue: GitlabOriginalIssue, + cfg: GitlabCfg, +): GitlabIssue => { return { html_url: issue.web_url, // eslint-disable-next-line id-blacklist @@ -27,7 +31,8 @@ export const mapGitlabIssue = (issue: GitlabOriginalIssue): GitlabIssue => { comments: [], url: issue.web_url, // NOTE: we use the issue number as id as well, as it there is not much to be done with the id with the api - id: issue.iid, + // when we can get issues from multiple projects we use full refence as id + id: issue.references.full, }; }; diff --git a/src/app/features/issue/providers/gitlab/gitlab-issue/gitlab-issue.model.ts b/src/app/features/issue/providers/gitlab/gitlab-issue/gitlab-issue.model.ts index f52583138..e33bd7fae 100644 --- a/src/app/features/issue/providers/gitlab/gitlab-issue/gitlab-issue.model.ts +++ b/src/app/features/issue/providers/gitlab/gitlab-issue/gitlab-issue.model.ts @@ -41,7 +41,7 @@ export type GitlabIssue = Readonly<{ comments: GitlabComment[]; url: string; // NOTE: we use the issue number as id as well, as it there is not much to be done with the id with the api - id: number; + id: number | string; // according to the docs: "Users on GitLab Starter, Bronze, or higher will also see the weight parameter" weight?: number; diff --git a/src/app/features/issue/providers/gitlab/gitlab.const.ts b/src/app/features/issue/providers/gitlab/gitlab.const.ts index accfe079a..b416d6eb2 100644 --- a/src/app/features/issue/providers/gitlab/gitlab.const.ts +++ b/src/app/features/issue/providers/gitlab/gitlab.const.ts @@ -15,6 +15,8 @@ export const DEFAULT_GITLAB_CFG: GitlabCfg = { isAutoPoll: false, isAutoAddToBacklog: false, filterUsername: null, + scope: 'created-by-me', + source: 'project', }; // NOTE: we need a high limit because git has low usage limits :( @@ -25,7 +27,7 @@ export const GITLAB_INITIAL_POLL_DELAY = GITHUB_INITIAL_POLL_DELAY + 8000; // export const GITLAB_POLL_INTERVAL = 15 * 1000; export const GITLAB_BASE_URL = 'https://gitlab.com/'; -export const GITLAB_API_BASE_URL = `${GITLAB_BASE_URL}api/v4/projects`; +export const GITLAB_API_BASE_URL = `${GITLAB_BASE_URL}api/v4`; export const GITLAB_PROJECT_REGEX = /(^[1-9][0-9]*$)|((\w-?|\.-?)+((\/|%2F)(\w-?|\.-?)+)+$)/i; @@ -41,6 +43,19 @@ export const GITLAB_CONFIG_FORM: LimitedFormlyFieldConfig[] = [ /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/, }, }, + { + key: 'source', + type: 'select', + defaultValue: 'project', + templateOptions: { + label: T.F.GITLAB.FORM.SOURCE, + options: [ + { value: 'project', label: T.F.GITLAB.FORM.SOURCE_PROJECT }, + { value: 'group', label: T.F.GITLAB.FORM.SOURCE_GROUP }, + { value: 'global', label: T.F.GITLAB.FORM.SOURCE_GLOBAL }, + ], + }, + }, { key: 'project', type: 'input', @@ -95,6 +110,19 @@ export const GITLAB_CONFIG_FORM: LimitedFormlyFieldConfig[] = [ label: T.F.GITLAB.FORM.FILTER_USER, }, }, + { + key: 'scope', + type: 'select', + defaultValue: 'created-by-me', + templateOptions: { + label: T.F.GITLAB.FORM.SCOPE, + options: [ + { value: 'all', label: T.F.GITLAB.FORM.SCOPE_ALL }, + { value: 'created-by-me', label: T.F.GITLAB.FORM.SCOPE_CREATED }, + { value: 'assigned-to-me', label: T.F.GITLAB.FORM.SCOPE_ASSIGNED }, + ], + }, + }, ]; export const GITLAB_CONFIG_FORM_SECTION: ConfigFormSection = { diff --git a/src/app/features/issue/providers/gitlab/gitlab.d.ts b/src/app/features/issue/providers/gitlab/gitlab.d.ts index 7600fb418..b2a3910f5 100644 --- a/src/app/features/issue/providers/gitlab/gitlab.d.ts +++ b/src/app/features/issue/providers/gitlab/gitlab.d.ts @@ -4,6 +4,8 @@ export interface GitlabCfg { isAutoPoll: boolean; filterUsername: string | null; gitlabBaseUrl: string | null | undefined; + source: string | null; project: string | null; token: string | null; + scope: string | null; } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 9d89ca2ec..91e0fabf9 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -167,6 +167,14 @@ const T = { IS_SEARCH_ISSUES_FROM_GITLAB: 'F.GITLAB.FORM.IS_SEARCH_ISSUES_FROM_GITLAB', PROJECT: 'F.GITLAB.FORM.PROJECT', TOKEN: 'F.GITLAB.FORM.TOKEN', + SCOPE: 'F.GITLAB.FORM.SCOPE', + SCOPE_ALL: 'F.GITLAB.FORM.SCOPE_ALL', + SCOPE_ASSIGNED: 'F.GITLAB.FORM.SCOPE_ASSIGNED', + SCOPE_CREATED: 'F.GITLAB.FORM.SCOPE_CREATED', + SOURCE: 'F.GITLAB.FORM.SOURCE', + SOURCE_GLOBAL: 'F.GITLAB.FORM.SOURCE_GLOBAL', + SOURCE_PROJECT: 'F.GITLAB.FORM.SOURCE_PROJECT', + SOURCE_GROUP: 'F.GITLAB.FORM.SOURCE_GROUP', }, FORM_SECTION: { HELP: 'F.GITLAB.FORM_SECTION.HELP', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 7e8fe9aef..565c2a627 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -165,8 +165,16 @@ "IS_AUTO_ADD_TO_BACKLOG": "Automatically add unresolved issues from GitLab to backlog", "IS_AUTO_POLL": "Automatically poll imported git issues for changes", "IS_SEARCH_ISSUES_FROM_GITLAB": "Show issues from git as suggestions when adding new tasks", - "PROJECT": "project ID or user name/project", - "TOKEN": "Access Token" + "PROJECT": "(default) project ID or user name/project", + "TOKEN": "Access Token", + "SCOPE": "Scope", + "SCOPE_ALL": "All", + "SCOPE_ASSIGNED": "Assigned to me", + "SCOPE_CREATED": "Created by me", + "SOURCE": "Source", + "SOURCE_GLOBAL": "All", + "SOURCE_PROJECT": "Project", + "SOURCE_GROUP": "Group" }, "FORM_SECTION": { "HELP": "

Here you can configure SuperProductivity to list open GitLab (either its the online version or a self-hosted instance) issues for a specific project in the task creation panel in the daily planning view. They will be listed as suggestions and will provide a link to the issue as well as more information about it.

In addition you can automatically add and sync all open issues to your task backlog.

",