Allow For Issues from multiple projects

This commit is contained in:
Clemens Bergmann 2021-09-26 15:16:27 +02:00 committed by Johannes Millan
parent ca28d83f65
commit 48e2b919f8
10 changed files with 239 additions and 87 deletions

15
.vscode/launch.json vendored Normal file
View File

@ -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}"
}
]
}

View File

@ -38,7 +38,7 @@ export interface IssueServiceInterface {
{
task: Task;
taskChanges: Partial<Task>;
issue: IssueData;
issue: IssueData | null;
}[]
>;

View File

@ -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<GitlabIssue> {
getById$(id: string, cfg: GitlabCfg): Observable<GitlabIssue> {
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<GitlabIssue[]> {
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<GitlabIssue[]> {
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<GitlabOriginalComment[]> {
@ -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;
}
}

View File

@ -43,25 +43,23 @@ export class GitlabCommonInterfacesService implements IssueServiceInterface {
return isGitlabEnabled(cfg);
}
issueLink$(issueId: number, projectId: string): Observable<string> {
issueLink$(issueId: string, projectId: string): Observable<string> {
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<GitlabIssue> {
getById$(issueId: string, projectId: string): Observable<GitlabIssue> {
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<Task>; 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<Task>; 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<string, GitlabIssue>();
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<string, string[]>();
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<Task>;
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<Task> & { 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<GitlabCfg> {

View File

@ -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,
};
};

View File

@ -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;

View File

@ -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<GitlabCfg>[] = [
/((([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<GitlabCfg>[] = [
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<GitlabCfg> = {

View File

@ -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;
}

View File

@ -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',

View File

@ -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": "<p>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.</p> <p>In addition you can automatically add and sync all open issues to your task backlog.</p>",