feature/issues_v1 #25

Open
DmitrySheyko wants to merge 12 commits from feature/issues_v1 into develop
44 changed files with 2056 additions and 5 deletions

View File

@ -0,0 +1,50 @@
package dev.struchkov.bot.gitlab.context.domain;
import dev.struchkov.bot.gitlab.context.domain.entity.Person;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author Dmitry Sheyko [25.01.2023]
*/
@Getter
@RequiredArgsConstructor
public enum AssigneesChanged {
BECOME(true),
DELETED(true),
NOT_AFFECT_USER(true),
NOT_CHANGED(false);
private final boolean changed;
public static AssigneesChanged valueOf(Long gitlabUserId, List<Person> oldAssignees, List<Person> newAssignees) {
final Map<Long, Person> oldMap = oldAssignees.stream().collect(Collectors.toMap(Person::getId, p -> p));
final Map<Long, Person> newMap = newAssignees.stream().collect(Collectors.toMap(Person::getId, p -> p));
if (!oldMap.keySet().equals(newMap.keySet())) {
if (oldMap.containsKey(gitlabUserId) && !newMap.containsKey(gitlabUserId)) {
return AssigneesChanged.DELETED;
}
if (!oldMap.containsKey(gitlabUserId) && newMap.containsKey(gitlabUserId)) {
return AssigneesChanged.BECOME;
}
return AssigneesChanged.NOT_AFFECT_USER;
}
return AssigneesChanged.NOT_CHANGED;
}
public boolean getNewStatus(boolean oldStatus) {
return switch (this) {
case BECOME -> true;
case DELETED -> false;
case NOT_AFFECT_USER, NOT_CHANGED -> oldStatus;
};
}
}

View File

@ -0,0 +1,20 @@
package dev.struchkov.bot.gitlab.context.domain;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
/**
* @author Dmotry Sheyko [25.01.2023]
*/
@Getter
@Setter
@AllArgsConstructor
public class IdAndStatusIssue {
private long id;
private long twoId;
private long projectId;
private IssueState status;
}

View File

@ -0,0 +1,10 @@
package dev.struchkov.bot.gitlab.context.domain;
/**
* @author Dmitry Sheyko 21.01.2021
*/
public enum IssueState {
OPENED, CLOSED
}

View File

@ -0,0 +1,10 @@
package dev.struchkov.bot.gitlab.context.domain;
/**
* @author Dmitry Sheyko 21.01.2021
*/
public enum IssueType {
ISSUE, INCIDENT
}

View File

@ -0,0 +1,145 @@
package dev.struchkov.bot.gitlab.context.domain.entity;
import dev.struchkov.bot.gitlab.context.domain.IssueState;
import dev.struchkov.bot.gitlab.context.domain.IssueType;
import dev.struchkov.haiti.utils.fieldconstants.annotation.FieldNames;
import dev.struchkov.haiti.utils.fieldconstants.domain.Mode;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Сущность Issue.
*
* @author Dmitry Sheyko [17.01.2023]
*/
@Getter
Review

Лишний перенос строки, и комментарий непонятно что означает, почему это стоит учитывать и почему этот комментарий тут? Пагинация передается и настраивается. Комментарий стоит удалить.

Лишний перенос строки, и комментарий непонятно что означает, почему это стоит учитывать и почему этот комментарий тут? Пагинация передается и настраивается. Комментарий стоит удалить.
Review

Правки внес.
Комментарий думал оставить временно, чтобы не забыть осоенность которую нашел в документации. Привычка везде оставлять напоминания.

Правки внес. Комментарий думал оставить временно, чтобы не забыть осоенность которую нашел в документации. Привычка везде оставлять напоминания.
@Setter
@Entity
@FieldNames(mode = {Mode.TABLE, Mode.SIMPLE})
@Table(name = "issue")
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Issue {
@Id
@Column(name = "id")
@EqualsAndHashCode.Include
private Long id;
@Column(name = "two_id")
private Long twoId;
@Column(name = "project_id")
private Long projectId;
@Column(name = "title")
private String title;
@Column(name = "description")
private String description;
@Enumerated(value = EnumType.STRING)
@Column(name = "state")
private IssueState state;
@Column(name = "created_date")
private LocalDateTime createdDate;
@Column(name = "updated_date")
private LocalDateTime updatedDate;
@Column(name = "closed_at")
private LocalDateTime closeDate;
@ManyToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinColumn(name = "closed_by_id")
private Person closedBy;
@ElementCollection
@CollectionTable(name = "issue_label", joinColumns = @JoinColumn(name = "issue_id"))
@Column(name = "label")
private Set<String> labels = new HashSet<>();
@OneToMany(
fetch = FetchType.LAZY,
cascade = {CascadeType.PERSIST, CascadeType.MERGE}
)
@JoinTable(
name = "issue_assignees",
joinColumns = @JoinColumn(name = "issue_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "person_id", referencedColumnName = "id")
)
private List<Person> assignees = new ArrayList<>();
@ManyToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinColumn(name = "author_id")
private Person author;
@Enumerated(value = EnumType.STRING)
@Column(name = "type")
private IssueType type;
@Column(name = "user_notes_count")
private Integer userNotesCount;
@Column(name = "merge_requests_count")
private Integer mergeRequestsCount;
@Column(name = "up_votes")
private Integer upVotes;
@Column(name = "down_votes")
private Integer downVotes;
@Column(name = "due_date")
private LocalDate dueDate;
@Column(name = "confidential")
private Boolean confidential;
@Column(name = "discussion_locked")
private Integer discussionLocked;
@Column(name = "task_count")
private Integer taskCount;
@Column(name = "task_completed_count")
private Integer taskCompletedCount;
@Column(name = "web_url")
private String webUrl;
@Column(name = "blocking_issues_count")
private Integer blockingIssuesCount;
@Column(name = "has_tasks")
private Boolean hasTasks;
@Column(name = "notification")
private boolean notification;
@Column(name = "is_assignee")
private boolean userAssignee;
}

View File

@ -0,0 +1,33 @@
package dev.struchkov.bot.gitlab.context.domain.notify.issue;
import lombok.Builder;
import lombok.Getter;
/**
* @author Dmitry Sheyko 25.01.2021
*/
@Getter
public class DeleteFromAssigneesNotify extends IssueNotify {
public static final String TYPE = "DeleteFromAssigneesOfIssueNotify";
private final String updateDate;
@Builder
public DeleteFromAssigneesNotify(
String projectName,
String title,
String url,
String issueType,
String updateDate
) {
super(projectName, title, url, issueType);
this.updateDate = updateDate;
}
@Override
public String getType() {
return TYPE;
}
}

View File

@ -0,0 +1,33 @@
package dev.struchkov.bot.gitlab.context.domain.notify.issue;
import lombok.Builder;
import lombok.Getter;
/**
* @author Dmitry Sheyko 25.01.2021
*/
@Getter
public class DescriptionIssueNotify extends IssueNotify {
public static final String TYPE = "DescriptionIssueNotify";
private final String newDescription;
@Builder
public DescriptionIssueNotify(
String projectName,
String title,
String url,
String issueType,
String newDescription
) {
super(projectName, title, url, issueType);
this.newDescription = newDescription;
}
@Override
public String getType() {
return TYPE;
}
}

View File

@ -0,0 +1,36 @@
package dev.struchkov.bot.gitlab.context.domain.notify.issue;
import lombok.Builder;
import lombok.Getter;
/**
* @author Dmitry Sheyko 25.01.2021
*/
@Getter
public class DueDateIssueNotify extends IssueNotify {
public static final String TYPE = "DueDateIssueNotify";
private final String oldDueDate;
private final String newDueDate;
@Builder
public DueDateIssueNotify(
String projectName,
String title,
String url,
String issueType,
String oldDueDate,
String newDueDate
) {
super(projectName, title, url, issueType);
this.oldDueDate = oldDueDate;
this.newDueDate = newDueDate;
}
@Override
public String getType() {
return TYPE;
}
}

View File

@ -0,0 +1,29 @@
package dev.struchkov.bot.gitlab.context.domain.notify.issue;
import dev.struchkov.bot.gitlab.context.domain.notify.Notify;
import lombok.Getter;
/**
* @author Dmitry Sheyko 23.01.2021
*/
@Getter
public abstract class IssueNotify implements Notify {
protected final String projectName;
protected final String title;
protected final String url;
protected final String issueType;
public IssueNotify(
String projectName,
String title,
String url,
String issueType
) {
this.projectName = projectName;
this.title = title;
this.url = url;
this.issueType = issueType;
}
}

View File

@ -0,0 +1,47 @@
package dev.struchkov.bot.gitlab.context.domain.notify.issue;
import lombok.Builder;
import lombok.Getter;
import java.util.Set;
/**
* @author Dmitry Sheyko 23.01.2021
*/
@Getter
public class NewIssueNotify extends IssueNotify {
public static final String TYPE = "NewIssueNotify";
private final String author;
private final String description;
private final String dueDate;
private final Set<String> labels;
private final String confidential;
@Builder
public NewIssueNotify(
String projectName,
String title,
String url,
String issueType,
String author,
String description,
String dueDate,
Set<String> labels,
String confidential
) {
super(projectName, title, url, issueType);
this.author = author;
this.description = description;
this.dueDate = dueDate;
this.labels = labels;
this.confidential = confidential;
}
@Override
public String getType() {
return TYPE;
}
}

View File

@ -0,0 +1,37 @@
package dev.struchkov.bot.gitlab.context.domain.notify.issue;
import dev.struchkov.bot.gitlab.context.domain.IssueState;
import lombok.Builder;
import lombok.Getter;
/**
* @author Dmitry Sheyko 23.01.2021
*/
@Getter
public class StatusIssueNotify extends IssueNotify{
public static final String TYPE = "StatusIssueNotify";
private final IssueState oldStatus;
private final IssueState newStatus;
@Builder
private StatusIssueNotify(
String name,
String url,
String projectName,
String issueType,
IssueState oldStatus,
IssueState newStatus
) {
super(projectName, name, url, issueType);
this.oldStatus = oldStatus;
this.newStatus = newStatus;
}
@Override
public String getType() {
return TYPE;
}
}

View File

@ -0,0 +1,33 @@
package dev.struchkov.bot.gitlab.context.domain.notify.issue;
import lombok.Builder;
import lombok.Getter;
/**
* @author Dmitry Sheyko 25.01.2021
*/
@Getter
public class TitleIssueNotify extends IssueNotify {
public static final String TYPE = "TitleIssueNotify";
private final String newTitle;
@Builder
public TitleIssueNotify(
String projectName,
String title,
String url,
String issueType,
String newTitle
) {
super(projectName, title, url, issueType);
this.newTitle = newTitle;
}
@Override
public String getType() {
return TYPE;
}
}

View File

@ -0,0 +1,37 @@
package dev.struchkov.bot.gitlab.context.domain.notify.issue;
import dev.struchkov.bot.gitlab.context.domain.IssueType;
import lombok.Builder;
import lombok.Getter;
/**
* @author Dmitry Sheyko 25.01.2021
*/
@Getter
public class TypeIssueNotify extends IssueNotify {
public static final String TYPE = "TypeIssueNotify";
private final IssueType oldType;
private final IssueType newType;
@Builder
public TypeIssueNotify(
String projectName,
String title,
String url,
String issueType,
IssueType oldType,
IssueType newType
) {
super(projectName, title, url, issueType);
this.oldType = oldType;
this.newType = newType;
}
@Override
public String getType() {
return TYPE;
}
}

View File

@ -0,0 +1,27 @@
package dev.struchkov.bot.gitlab.context.repository;
import dev.struchkov.bot.gitlab.context.domain.IdAndStatusIssue;
import dev.struchkov.bot.gitlab.context.domain.IssueState;
import dev.struchkov.bot.gitlab.context.domain.entity.Issue;
import lombok.NonNull;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* @author Dmitry Sheyko [24.01.2023]
*/
public interface IssueRepository {
Set<IdAndStatusIssue> findAllIdByStateIn(@NonNull Set<IssueState> states);
Issue save(Issue issue);
Optional<Issue> findById(Long issueId);
List<Issue> findAllById(Set<Long> mergeRequestIds);
void deleteByStates(Set<IssueState> states);
}

View File

@ -0,0 +1,29 @@
package dev.struchkov.bot.gitlab.context.service;
import dev.struchkov.bot.gitlab.context.domain.*;
import dev.struchkov.bot.gitlab.context.domain.entity.Issue;
import lombok.NonNull;
import java.util.List;
import java.util.Set;
/**
* @author Dmitry Sheyko [24.01.2023]
*/
public interface IssueService {
Issue create(@NonNull Issue issue);
Issue update(@NonNull Issue issue);
List<Issue> updateAll(@NonNull List<Issue> issues);
ExistContainer<Issue, Long> existsById(@NonNull Set<Long> issueIds);
List<Issue> createAll(List<Issue> issues);
Set<IdAndStatusIssue> getAllId(Set<IssueState> statuses);
void cleanOld();
}

View File

@ -28,6 +28,7 @@ public class Icons {
public static final String NO = "";
public static final String NOTIFY = "\uD83D\uDD14";
public static final String GOOD = "\uD83D\uDC4D";
public static final String BELL ="\uD83D\uDD14";
private Icons() {
utilityClass();
@ -37,4 +38,4 @@ public class Icons {
return "[" + escapeMarkdown(title) + "](" + url + ")";
}
}
}

View File

@ -68,4 +68,11 @@ public class GitlabProperty {
private String discussionUrl;
/**
* Адрес, по которому можно получить ISSUE
*/
private String issueUrl;
private String openIssueUrl;
}

View File

@ -0,0 +1,97 @@
package dev.struchkov.bot.gitlab.core.service.convert;
import dev.struchkov.bot.gitlab.context.domain.IssueState;
import dev.struchkov.bot.gitlab.context.domain.IssueType;
import dev.struchkov.bot.gitlab.context.domain.entity.Issue;
import dev.struchkov.bot.gitlab.context.domain.entity.Person;
import dev.struchkov.bot.gitlab.sdk.domain.*;
import lombok.RequiredArgsConstructor;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static dev.struchkov.haiti.utils.Checker.checkNotEmpty;
import static dev.struchkov.haiti.utils.Checker.checkNotNull;
/**
* @author Dmitry Sheyko [22.01.2023]
*/
@Component
@RequiredArgsConstructor
public class IssueJsonConverter implements Converter<IssueJson, Issue> {
private final PersonJsonConverter convertPerson;
@Override
public Issue convert(IssueJson source) {
final Issue issue = new Issue();
issue.setId(source.getId());
issue.setTwoId(source.getTwoId());
issue.setProjectId(source.getProjectId());
issue.setTitle(source.getTitle());
issue.setDescription(source.getDescription());
issue.setState(convertState(source.getState()));
issue.setCreatedDate(source.getCreatedDate());
issue.setUpdatedDate(source.getUpdatedDate());
issue.setCloseDate(source.getClosedDate());
issue.setType(convertType(source.getType()));
issue.setUserNotesCount(source.getUserNotesCount());
issue.setMergeRequestsCount(source.getMergeRequestsCount());
issue.setUpVotes(source.getUpVotes());
issue.setDownVotes(source.getDownVotes());
issue.setDueDate(source.getDueDate());
issue.setConfidential(source.getConfidential());
issue.setDiscussionLocked(source.getDiscussionLocked());
issue.setTaskCount(source.getTaskCompletionStatus().getCount());
issue.setTaskCompletedCount(source.getTaskCompletionStatus().getCompletedCount());
issue.setWebUrl(source.getWebUrl());
issue.setBlockingIssuesCount(source.getBlockingIssuesCount());
issue.setHasTasks(source.getHasTasks());
convertAssignees(issue, source.getAssignees());
convertLabels(issue, source.getLabels());
if (checkNotNull(source.getClosedBy())) {
issue.setClosedBy(convertPerson.convert(source.getClosedBy()));
}
issue.setAuthor(convertPerson.convert(source.getAuthor()));
return issue;
}
private void convertAssignees(Issue issue, List<PersonJson> jsonAssignees) {
if (checkNotEmpty(jsonAssignees)) {
final List<Person> assignees = jsonAssignees.stream()
.map(convertPerson::convert)
.toList();
issue.setAssignees(assignees);
}
}
private void convertLabels(Issue issue, Set<String> source) {
if (checkNotEmpty(source)) {
final Set<String> labels = source.stream()
.map(label -> label.replace("-", "_"))
.collect(Collectors.toSet());
issue.setLabels(labels);
}
}
private IssueState convertState(IssueStateJson state) {
return switch (state) {
case CLOSED -> IssueState.CLOSED;
case OPENED -> IssueState.OPENED;
};
}
private IssueType convertType(IssueTypeJson type) {
return switch (type) {
case ISSUE -> IssueType.ISSUE;
case INCIDENT -> IssueType.INCIDENT;
};
}
}

View File

@ -0,0 +1,313 @@
package dev.struchkov.bot.gitlab.core.service.impl;
import dev.struchkov.bot.gitlab.context.domain.*;
import dev.struchkov.bot.gitlab.context.domain.entity.Issue;
import dev.struchkov.bot.gitlab.context.domain.entity.Person;
import dev.struchkov.bot.gitlab.context.domain.entity.Project;
import dev.struchkov.bot.gitlab.context.domain.notify.issue.*;
import dev.struchkov.bot.gitlab.context.repository.IssueRepository;
import dev.struchkov.bot.gitlab.context.service.IssueService;
import dev.struchkov.bot.gitlab.context.service.NotifyService;
import dev.struchkov.bot.gitlab.context.service.ProjectService;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static dev.struchkov.bot.gitlab.context.domain.IssueState.CLOSED;
import static dev.struchkov.haiti.context.exception.NotFoundException.notFoundException;
import static dev.struchkov.haiti.utils.Checker.checkNotEmpty;
/**
* @author Dmitry Sheyko [25.01.2023]
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IssueServiceImpl implements IssueService {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
public static final Set<IssueState> DELETE_STATES = Set.of(CLOSED);
private final PersonInformation personInformation;
private final IssueRepository repository;
private final ProjectService projectService;
private final NotifyService notifyService;
@Override
@Transactional
public Issue create(@NonNull Issue issue) {
final boolean botUserAssignee = isBotUserAssignee(issue);
final boolean botUserAssigneeAndNotAuthor = isBotUserAssigneeAndNotAuthor(issue);
issue.setUserAssignee(botUserAssignee);
issue.setNotification(botUserAssigneeAndNotAuthor);
final Issue savedIssue = repository.save(issue);
if (botUserAssigneeAndNotAuthor) {
final String projectName = projectService.getByIdOrThrow(savedIssue.getProjectId()).getName();
sendNotifyAboutAssignee(issue, projectName);
}
return savedIssue;
}
private boolean isBotUserAssignee(Issue savedIssue) {
final Long gitlabUserId = personInformation.getId();
final List<Person> assignees = savedIssue.getAssignees();
if (checkNotEmpty(assignees)) {
for (Person assignee : assignees) {
if (gitlabUserId.equals(assignee.getId())) {
return true;
}
}
}
return false;
}
private boolean isBotUserAssigneeAndNotAuthor(Issue savedIssue) {
final Long gitlabUserId = personInformation.getId();
final boolean botUserAssignee = isBotUserAssignee(savedIssue);
if (botUserAssignee) {
return !gitlabUserId.equals(savedIssue.getAuthor().getId());
}
return false;
}
private void sendNotifyAboutAssignee(Issue issue, String projectName) {
final Long gitlabUserId = personInformation.getId();
if (!gitlabUserId.equals(issue.getAuthor().getId()) // создатель Issue не является пользователем бота
)
notifyService.send(
NewIssueNotify.builder()
.projectName(projectName)
.title(issue.getTitle())
.url(issue.getWebUrl())
.issueType(issue.getType().name())
.author(issue.getAuthor().getName())
.description(issue.getDescription())
.dueDate(issue.getDueDate().format(DATE_FORMAT))
.labels(issue.getLabels())
.confidential(issue.getConfidential().toString())
.build()
);
}
private void sendNotifyAboutDeleteFromAssignees(Issue issue, String projectName) {
final Long gitlabUserId = personInformation.getId();
if (!gitlabUserId.equals(issue.getAuthor().getId()) // создатель Issue не является пользователем бота
)
notifyService.send(
DeleteFromAssigneesNotify.builder()
.projectName(projectName)
.title(issue.getTitle())
.url(issue.getWebUrl())
.issueType(issue.getType().name())
.updateDate(issue.getUpdatedDate().format(DATE_FORMAT))
.build()
);
}
@Override
@Transactional
public Issue update(@NonNull Issue issue) {
final Issue oldIssue = repository.findById(issue.getId())
.orElseThrow(notFoundException("Issue не найдено"));
issue.setNotification(oldIssue.isNotification());
final Long gitlabUserId = personInformation.getId();
/**
* проверяем изменения списка Assignees: пользователь появился в списке или удален из него или без изменений.
*/
final AssigneesChanged assigneesChanged = AssigneesChanged.valueOf(gitlabUserId, oldIssue.getAssignees(), issue.getAssignees());
issue.setUserAssignee(assigneesChanged.getNewStatus(oldIssue.isUserAssignee()));
final boolean isChangedIssue = !oldIssue.getUpdatedDate().equals(issue.getUpdatedDate());
/**
* Удаление пользователя из assignee не всегда обновляет UpdatedDate, поэтому добавляется
* второе условие assigneesChanged.isChanged()
*/
if (isChangedIssue || assigneesChanged.isChanged()) {
if (assigneesChanged.equals(AssigneesChanged.BECOME) && !gitlabUserId.equals(issue.getAuthor().getId()))
issue.setNotification(true);
if (issue.isNotification()) {
final Project project = projectService.getByIdOrThrow(issue.getProjectId());
notifyAboutStatus(oldIssue, issue, project);
notifyAboutType(oldIssue, issue, project);
notifyAboutTitle(oldIssue, issue, project);
notifyAboutDescription(oldIssue, issue, project);
notifyAboutDueDate(oldIssue, issue, project);
notifyAboutChangeAssignees(assigneesChanged, issue, project);
}
return repository.save(issue);
}
return oldIssue;
}
@Override
public ExistContainer<Issue, Long> existsById(@NonNull Set<Long> issueIds) {
final List<Issue> existsEntity = repository.findAllById(issueIds);
final Set<Long> existsIds = existsEntity.stream().map(Issue::getId).collect(Collectors.toSet());
if (existsIds.containsAll(issueIds)) {
return ExistContainer.allFind(existsEntity);
} else {
final Set<Long> noExistsId = issueIds.stream()
.filter(id -> !existsIds.contains(id))
.collect(Collectors.toSet());
return ExistContainer.notAllFind(existsEntity, noExistsId);
}
}
@Override
public List<Issue> createAll(List<Issue> newIssues) {
return newIssues.stream()
.map(this::create)
.toList();
}
@Override
@Transactional
public List<Issue> updateAll(@NonNull List<Issue> issues) {
return issues.stream()
.map(this::update)
.collect(Collectors.toList());
}
@Override
public Set<IdAndStatusIssue> getAllId(Set<IssueState> statuses) {
return repository.findAllIdByStateIn(statuses);
}
protected void notifyAboutChangeAssignees(AssigneesChanged assigneesChanged, Issue issue, Project project) {
switch (assigneesChanged) {
case BECOME -> sendNotifyAboutAssignee(issue, project.getName());
case DELETED -> {
sendNotifyAboutDeleteFromAssignees(issue, project.getName());
issue.setUserAssignee(false);
issue.setNotification(false);
}
}
}
protected void notifyAboutTitle(Issue oldIssue, Issue newIssue, Project project) {
final String oldTitle = oldIssue.getTitle();
final String newTitle = newIssue.getTitle();
final Long gitlabUserId = personInformation.getId();
if (
!oldTitle.equals(newTitle) // заголовок изменился
&& !gitlabUserId.equals(oldIssue.getAuthor().getId()) // создатель Issue не является пользователем бота
) {
notifyService.send(
TitleIssueNotify.builder()
.projectName(project.getName())
.title(oldIssue.getTitle())
.url(oldIssue.getWebUrl())
.issueType(oldIssue.getType().name())
.newTitle(newTitle)
.build()
);
}
}
protected void notifyAboutDescription(Issue oldIssue, Issue newIssue, Project project) {
final String oldDescription = oldIssue.getDescription();
final String newDescription = newIssue.getDescription();
final Long gitlabUserId = personInformation.getId();
if (
!oldDescription.equals(newDescription) // описание изменилось
&& !gitlabUserId.equals(oldIssue.getAuthor().getId()) // создатель Issue не является пользователем бота
) {
notifyService.send(
DescriptionIssueNotify.builder()
.projectName(project.getName())
.title(oldIssue.getTitle())
.url(oldIssue.getWebUrl())
.issueType(oldIssue.getType().name())
.newDescription(newDescription)
.build()
);
}
}
protected void notifyAboutType(Issue oldIssue, Issue newIssue, Project project) {
final IssueType oldType = oldIssue.getType();
final IssueType newType = newIssue.getType();
final Long gitlabUserId = personInformation.getId();
if (
!oldType.equals(newType) // тип изменился
&& !gitlabUserId.equals(oldIssue.getAuthor().getId()) // создатель Issue не является пользователем бота
) {
notifyService.send(
TypeIssueNotify.builder()
.projectName(project.getName())
.title(oldIssue.getTitle())
.url(oldIssue.getWebUrl())
.issueType(oldIssue.getType().name())
.oldType(oldType)
.newType(newType)
.build()
);
}
}
protected void notifyAboutStatus(Issue oldIssue, Issue newIssue, Project project) {
final IssueState oldStatus = oldIssue.getState();
final IssueState newStatus = newIssue.getState();
final Long gitlabUserId = personInformation.getId();
if (
!oldStatus.equals(newStatus) // статус изменился
&& gitlabUserId.equals(oldIssue.getAuthor().getId()) // создатель Issue является пользователем бота
) {
notifyService.send(
StatusIssueNotify.builder()
.name(newIssue.getTitle())
.url(oldIssue.getWebUrl())
.issueType(oldIssue.getType().name())
.projectName(project.getName())
.newStatus(newStatus)
.oldStatus(oldStatus)
.build()
);
}
}
protected void notifyAboutDueDate(Issue oldIssue, Issue newIssue, Project project) {
final String oldDueDate = oldIssue.getDueDate().format(DATE_FORMAT);
final String newDueDate = newIssue.getDueDate().format(DATE_FORMAT);
final Long gitlabUserId = personInformation.getId();
if (
(!Objects.equals(oldDueDate, newDueDate)) // дата изменилась
&& (!gitlabUserId.equals(oldIssue.getAuthor().getId())) // создатель Issue не является пользователем бота
) {
notifyService.send(
DueDateIssueNotify.builder()
.projectName(project.getName())
.title(oldIssue.getTitle())
.url(oldIssue.getWebUrl())
.issueType(oldIssue.getType().name())
.oldDueDate(oldDueDate)
.newDueDate(newDueDate)
.build()
);
}
}
@Override
public void cleanOld() {
log.debug("Старт очистки старых Issue");
repository.deleteByStates(DELETE_STATES);
log.debug("Конец очистки старых Issue");
}
}

View File

@ -0,0 +1,166 @@
package dev.struchkov.bot.gitlab.core.service.parser;
import dev.struchkov.bot.gitlab.context.domain.*;
import dev.struchkov.bot.gitlab.context.domain.entity.Issue;
import dev.struchkov.bot.gitlab.context.domain.entity.Person;
import dev.struchkov.bot.gitlab.context.service.IssueService;
import dev.struchkov.bot.gitlab.context.service.ProjectService;
import dev.struchkov.bot.gitlab.core.config.properties.GitlabProperty;
import dev.struchkov.bot.gitlab.core.config.properties.PersonProperty;
import dev.struchkov.bot.gitlab.core.service.parser.forktask.GetAllIssueForProjectTask;
import dev.struchkov.bot.gitlab.core.service.parser.forktask.GetSingleIssueTask;
import dev.struchkov.bot.gitlab.sdk.domain.IssueJson;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.convert.ConversionService;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static dev.struchkov.haiti.utils.Checker.checkNotEmpty;
import static dev.struchkov.haiti.utils.concurrent.ForkJoinUtils.pullTaskResult;
import static dev.struchkov.haiti.utils.concurrent.ForkJoinUtils.pullTaskResults;
/**
* @author Dmotry Sheyko [24.01.2023]
*/
@Slf4j
@Service
public class IssueParser {
private static final Set<IssueState> OLD_STATUSES = Set.of(IssueState.OPENED, IssueState.CLOSED);
private final GitlabProperty gitlabProperty;
private final IssueService issueService;
private final ProjectService projectService;
private final ConversionService conversionService;
private final PersonProperty personProperty;
private final ForkJoinPool forkJoinPool;
public IssueParser(
GitlabProperty gitlabProperty,
IssueService issueService,
ProjectService projectService,
ConversionService conversionService,
PersonProperty personProperty,
@Qualifier("parserPool") ForkJoinPool forkJoinPool
) {
this.gitlabProperty = gitlabProperty;
this.issueService = issueService;
this.projectService = projectService;
this.conversionService = conversionService;
this.personProperty = personProperty;
this.forkJoinPool = forkJoinPool;
}
public void parsingOldIssue(){
log.debug("Старт обработаки старых Issues");
final Set<IdAndStatusIssue> existIds = issueService.getAllId(OLD_STATUSES);
final List<Issue> newIssues = getOldIssues(existIds).stream()
.map(issueJson -> {
final Issue newIssue = conversionService.convert(issueJson, Issue.class);
return newIssue;
})
.collect(Collectors.toList());
if (checkNotEmpty(newIssues)) {
personMapping(newIssues);
issueService.updateAll(newIssues);
}
log.debug("Конец обработки старых Issues");
}
private List<IssueJson> getOldIssues(Set<IdAndStatusIssue> existIds) {
final List<ForkJoinTask<Optional<IssueJson>>> tasks = existIds.stream()
.map(
existId -> new GetSingleIssueTask(
gitlabProperty.getIssueUrl(),
existId.getProjectId(),
existId.getTwoId(),
personProperty.getToken()
)
).map(forkJoinPool::submit)
.collect(Collectors.toList());
return pullTaskResult(tasks).stream()
.flatMap(Optional::stream)
.collect(Collectors.toList());
}
public void parsingNewIssue() {
log.debug("Старт обработки новых Issues");
/*
* получаем через репозиторий список id всех проектов хранящихся в нашей БД
*/
final Set<Long> projectIds = projectService.getAllIds();
/*
* На основе id проекта, url для получения issues по id проекта и токена пользователя
* выгружаем из GitLab список всех IssueJson. Получаем в многопоточном режиме.
*/
final List<IssueJson> issueJsons = getIssues(projectIds);
/*
* Получаем id всех IssueJson загруженных из GitLab
*/
if (checkNotEmpty(issueJsons)) {
final Set<Long> jsonIds = issueJsons.stream()
.map(IssueJson::getId)
.collect(Collectors.toSet());
final ExistContainer<Issue, Long> existContainer = issueService.existsById(jsonIds);
log.trace("Из {} полученных Issues не найдены в хранилище {}", jsonIds.size(), existContainer.getIdNoFound().size());
if (!existContainer.isAllFound()) {
final List<Issue> newIssues = issueJsons.stream()
.filter(json -> existContainer.getIdNoFound().contains(json.getId()))
.map(json -> {
final Issue issue = conversionService.convert(json, Issue.class);
return issue;
})
.toList();
log.trace("Пачка новых Issues обработана и отправлена на сохранение. Количество: {} шт.", newIssues.size());
issueService.createAll(newIssues);
}
}
log.debug("Конец обработки новых Issues");
}
private List<IssueJson> getIssues(Set<Long> projectIds) {
final List<ForkJoinTask<List<IssueJson>>> tasks = projectIds.stream()
.map(projectId -> new GetAllIssueForProjectTask(projectId, gitlabProperty.getOpenIssueUrl(), personProperty.getToken()))
.map(forkJoinPool::submit)
.collect(Collectors.toList());
return pullTaskResults(tasks);
}
private static void personMapping(List<Issue> newIssues) {
final Map<Long, Person> personMap = Stream.concat(
newIssues.stream()
.map(Issue::getAuthor),
newIssues.stream()
.flatMap(issue -> issue.getAssignees().stream())
).distinct()
.filter(Objects::nonNull)
.collect(Collectors.toMap(Person::getId, p -> p));
for (Issue newIssue : newIssues) {
newIssue.setAuthor(personMap.get(newIssue.getAuthor().getId()));
newIssue.setAssignees(
newIssue.getAssignees().stream()
.map(reviewer -> personMap.get(reviewer.getId()))
.collect(Collectors.toList())
);
}
}
}

View File

@ -0,0 +1,55 @@
package dev.struchkov.bot.gitlab.core.service.parser.forktask;
import dev.struchkov.bot.gitlab.core.utils.StringUtils;
import dev.struchkov.bot.gitlab.sdk.domain.IssueJson;
import dev.struchkov.bot.gitlab.core.utils.HttpParse;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import java.text.MessageFormat;
import java.util.List;
import java.util.concurrent.RecursiveTask;
import static dev.struchkov.haiti.utils.Checker.checkNotEmpty;
import static dev.struchkov.bot.gitlab.core.utils.HttpParse.ACCEPT;
/**
* @author Dmitry Sheyko [24.01.2023]
*/
@Slf4j
@AllArgsConstructor
@RequiredArgsConstructor
public class GetAllIssueForProjectTask extends RecursiveTask<List<IssueJson>> {
private static final int PAGE_COUNT = 100;
private final long projectId;
private int pageNumber = 1;
private final String urlIssueOpen;
private final String gitlabToken;
@Override
@SneakyThrows
protected List<IssueJson> compute() {
Thread.sleep(200);
final List<IssueJson> issueJson = getIssueJsons();
if (checkNotEmpty(issueJson) && issueJson.size() == PAGE_COUNT) {
final GetAllIssueForProjectTask newTask = new GetAllIssueForProjectTask(projectId, pageNumber + 1, urlIssueOpen, gitlabToken);
newTask.fork();
issueJson.addAll(newTask.join());
}
return issueJson;
}
private List<IssueJson> getIssueJsons() {
final List<IssueJson> jsons = HttpParse.request(MessageFormat.format(urlIssueOpen, projectId, pageNumber, PAGE_COUNT))
.header(StringUtils.H_PRIVATE_TOKEN, gitlabToken)
.header(ACCEPT)
.executeList(IssueJson.class);
log.trace("Получено {} шт потенциально новых Issue для проекта id:'{}' ", jsons.size(), projectId);
return jsons;
}
}

View File

@ -0,0 +1,36 @@
package dev.struchkov.bot.gitlab.core.service.parser.forktask;
import dev.struchkov.bot.gitlab.core.utils.StringUtils;
import dev.struchkov.bot.gitlab.sdk.domain.IssueJson;
import dev.struchkov.bot.gitlab.core.utils.HttpParse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import java.text.MessageFormat;
import java.util.Optional;
import java.util.concurrent.RecursiveTask;
import static dev.struchkov.bot.gitlab.core.utils.HttpParse.ACCEPT;
@Slf4j
@RequiredArgsConstructor
public class GetSingleIssueTask extends RecursiveTask<Optional<IssueJson>> {
private final String urlIssue;
private final long projectId;
private final long issueTwoId;
private final String gitlabToken;
@Override
@SneakyThrows
protected Optional<IssueJson> compute() {
Thread.sleep(200);
final String mrUrl = MessageFormat.format(urlIssue, projectId, issueTwoId);
return HttpParse.request(mrUrl)
.header(ACCEPT)
.header(StringUtils.H_PRIVATE_TOKEN, gitlabToken)
.execute(IssueJson.class);
}
}

View File

@ -0,0 +1,56 @@
package dev.struchkov.bot.gitlab.data.impl;
import dev.struchkov.bot.gitlab.context.domain.IdAndStatusIssue;
import dev.struchkov.bot.gitlab.context.domain.IssueState;
import dev.struchkov.bot.gitlab.context.domain.entity.Issue;
import dev.struchkov.bot.gitlab.context.repository.IssueRepository;
import dev.struchkov.bot.gitlab.data.jpa.IssueJpaRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* @author Dmitry Sheyko [24.01.2023]
*/
@Repository
@RequiredArgsConstructor
public class IssueRepositoryImpl implements IssueRepository {
private final IssueJpaRepository jpaRepository;
@Override
@Transactional(readOnly = true)
public Set<IdAndStatusIssue> findAllIdByStateIn(@NonNull Set<IssueState> statuses) {
return jpaRepository.findAllIdByStateIn(statuses);
}
@Override
@Transactional
public Issue save(Issue issue) {
return jpaRepository.save(issue);
}
@Override
@Transactional(readOnly = true)
public Optional<Issue> findById(Long issueId) {
return jpaRepository.findById(issueId);
}
@Override
@Transactional(readOnly = true)
public List<Issue> findAllById(Set<Long> issueIds) {
return jpaRepository.findAllById(issueIds);
}
@Override
@Transactional
public void deleteByStates(Set<IssueState> states) {
jpaRepository.deleteAllByStateIn(states);
}
}

View File

@ -0,0 +1,22 @@
package dev.struchkov.bot.gitlab.data.jpa;
import dev.struchkov.bot.gitlab.context.domain.IdAndStatusIssue;
import dev.struchkov.bot.gitlab.context.domain.IssueState;
import dev.struchkov.bot.gitlab.context.domain.entity.Issue;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Set;
/**
* @author Dmitry Sheyko [24.01.2023]
*/
public interface IssueJpaRepository extends JpaRepository<Issue, Long> {
@Query("SELECT new dev.struchkov.bot.gitlab.context.domain.IdAndStatusIssue(i.id, i.twoId, i.projectId, i.state) FROM Issue i WHERE i.state IN :states")
Set<IdAndStatusIssue> findAllIdByStateIn(@Param("states") Set<IssueState> states);
void deleteAllByStateIn(Set<IssueState> states);
}

View File

@ -1,10 +1,12 @@
package dev.struchkov.bot.gitlab.scheduler;
import dev.struchkov.bot.gitlab.context.service.AppSettingService;
import dev.struchkov.bot.gitlab.context.service.IssueService;
import dev.struchkov.bot.gitlab.context.service.DiscussionService;
import dev.struchkov.bot.gitlab.context.service.MergeRequestsService;
import dev.struchkov.bot.gitlab.context.service.PipelineService;
import dev.struchkov.bot.gitlab.core.service.parser.DiscussionParser;
import dev.struchkov.bot.gitlab.core.service.parser.IssueParser;
import dev.struchkov.bot.gitlab.core.service.parser.MergeRequestParser;
import dev.struchkov.bot.gitlab.core.service.parser.PipelineParser;
import dev.struchkov.bot.gitlab.core.service.parser.ProjectParser;
@ -32,9 +34,12 @@ public class SchedulerService {
private final MergeRequestsService mergeRequestsService;
private final DiscussionService discussionService;
private final IssueParser issueParser;
private final IssueService issueService;
@Scheduled(cron = "${gitlab-bot.cron.scan.new-project}")
public void newProjects() {
log.info("Запуск процесса получение новых репозиториев c GitLab");
log.info("Запуск процесса получения новых репозиториев c GitLab");
if (!settingService.isFirstStart()) {
if (settingService.isOwnerProjectScan()) {
projectParser.parseAllProjectOwner();
@ -43,16 +48,29 @@ public class SchedulerService {
projectParser.parseAllPrivateProject();
}
}
log.info("Конец процесса получение новых репозиториев c GitLab");
log.info("Конец процесса получения новых репозиториев c GitLab");
}
@Scheduled(cron = "0 */1 * * * *")
@Scheduled(cron = "${gitlab-bot.cron.scan.new-merge-request}")
public void newMergeRequests() {
log.info("Запуск процесса получение новых MR c GitLab");
log.info("Запуск процесса получения новых MR c GitLab");
if (!settingService.isFirstStart()) {
mergeRequestParser.parsingNewMergeRequest();
}
log.info("Конец процесса получение новых MR c GitLab");
log.info("Конец процесса получения новых MR c GitLab");
}
@Scheduled(cron = "0 */1 * * * *")
@Scheduled(cron = "${gitlab-bot.cron.scan.new-merge-request}")
public void newIssues() {
log.info("Запуск процесса получения новых Issues c GitLab");
if (!settingService.isFirstStart()) {
issueParser.parsingNewIssue();
}
log.info("Конец процесса получения новых Issues c GitLab");
}
@Scheduled(cron = "${gitlab-bot.cron.scan.general}")
@ -67,6 +85,8 @@ public class SchedulerService {
mergeRequestsService.cleanOld();
discussionService.cleanOld();
pipelineService.cleanOld();
issueParser.parsingOldIssue();
issueService.cleanOld();
} else {
log.warn("Процесс обновления данных не был выполнен, так как пользователь не выполнил первичную настройку.");
}

View File

@ -16,6 +16,13 @@ spring:
jdbc:
lob:
non_contextual_creation: true
# без данной настройк ипостоянно выбрасывается исключение:
# org.springframework.dao.InvalidDataAccessApiUsageException: Multiple representations of the
# same entity [dev.struchkov.bot.gitlab.context.domain.entity.Person#13445232] are being merged.
# Detached: [dev.struchkov.bot.gitlab.context.domain.entity.Person@cd28ab];
# Detached: [dev.struchkov.bot.gitlab.context.domain.entity.Person@cd28ab]
event:
merge.entity_copy_observer: allow
logging:
level:
@ -28,6 +35,12 @@ telegram:
autoresponder:
threads: ${AUTORESPONDER_THREADS:8}
proxy:
event:
merge.entity_copy_observer: allow
telegram-config:
bot-username: ${TELEGRAM_BOT_USERNAME}
bot-token: ${TELEGRAM_BOT_TOKEN}
proxy-config:
enable: ${PROXY_ENABLE:false}
host: ${PROXY_HOST:}
port: ${PROXY_PORT:}
@ -64,6 +77,8 @@ gitlab-bot:
new-note-url: "${GITLAB_URL}/api/v4/projects/{0,number,#}/merge_requests/{1,number,#}/discussions/{2}/notes?body={3}"
discussions-url: "${GITLAB_URL}/api/v4/projects/{0,number,#}/merge_requests/{1,number,#}/discussions?&page={2,number,#}&per_page={3,number,#}"
discussion-url: "${GITLAB_URL}/api/v4/projects/{0,number,#}/merge_requests/{1,number,#}/discussions/{2}"
issue-url: "${GITLAB_URL}/api/v4/projects/{0,number,#}/issues/{1,number,#}"
open-issue-url: "${GITLAB_URL}/api/v4/projects/{0,number,#}/issues?state=opened&page={1, number, integer}&per_page={2, number, integer}"
---
spring:

View File

@ -0,0 +1,114 @@
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.17.xsd">
<changeSet id="2023-01-19-create-table-issue" author="Dmitry Sheyko">
<createTable tableName="issue">
<column name="id" type="int">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="two_id" type="int">
<constraints nullable="false"/>
</column>
<column name="project_id" type="int">
<constraints nullable="false"/>
</column>
<column name="title" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="description" type="varchar(2000)">
<constraints nullable="true"/>
</column>
<column name="state" type="varchar(10)">
<constraints nullable="false"/>
</column>
<column name="created_date" type="datetime">
<constraints nullable="false"/>
</column>
<column name="updated_date" type="datetime">
<constraints nullable="true"/>
</column>
<column name="closed_at" type="datetime">
<constraints nullable="true"/>
</column>
<column name="closed_by_id" type="int">
<constraints nullable="true" foreignKeyName="fk_issue_closed_by_id_person_id" references="person(id)"/>
</column>
<column name="author_id" type="int">
<constraints nullable="false" foreignKeyName="fk_issue_author_id_person_id" references="person(id)"/>
</column>
<column name="type" type="varchar(10)">
<constraints nullable="false"/>
</column>
<column name="user_notes_count" type="int">
<constraints nullable="false"/>
</column>
<column name="merge_requests_count" type="int">
<constraints nullable="false"/>
</column>
<column name="up_votes" type="int">
<constraints nullable="false"/>
</column>
<column name="down_votes" type="int">
<constraints nullable="false"/>
</column>
<column name="due_date" type="datetime">
<constraints nullable="true"/>
</column>
<column name="confidential" type="boolean">
<constraints nullable="false"/>
</column>
<column name="discussion_locked" type="int">
<constraints nullable="true"/>
</column>
<column name="task_count" type="int">
<constraints nullable="false"/>
</column>
<column name="task_completed_count" type="int">
<constraints nullable="false"/>
</column>
<column name="web_url" type="varchar(300)">
<constraints nullable="false"/>
</column>
<column name="blocking_issues_count" type="int">
<constraints nullable="false"/>
</column>
<column name="has_tasks" type="boolean">
<constraints nullable="false"/>
</column>
<column name="notification" type="boolean">
<constraints nullable="false"/>
</column>
<column name="is_assignee" type="boolean">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="2023-01-19-create-table-issue_label" author="Dmitry Sheyko">
<createTable tableName="issue_label">
<column name="issue_id" type="int">
<constraints nullable="false" foreignKeyName="fk_issue_label_issue_id"
references="issue(id)" deleteCascade="true"/>
</column>
<column name="label" type="varchar(255)">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="2023-01-19-create-table-issue_assignees" author="Dmitry Sheyko">
<createTable tableName="issue_assignees">
<column name="issue_id" type="int">
<constraints nullable="false" foreignKeyName="fk_issue_assignees_issue_id" references="issue(id)"/>
</column>
<column name="person_id" type="int">
<constraints nullable="false" foreignKeyName="fk_issue_assignees_person_id" references="person(id)"/>
</column>
</createTable>
<addPrimaryKey tableName="issue_assignees" columnNames="issue_id, person_id"/>
</changeSet>
</databaseChangeLog>

View File

@ -9,5 +9,6 @@
<include file="2022-12-03-create-tables.xml" relativeToChangelogFile="true"/>
<include file="2022-12-03-insert.xml" relativeToChangelogFile="true"/>
<include file="2023-01-19-create-tables-for-issue.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>

View File

@ -0,0 +1,113 @@
package dev.struchkov.bot.gitlab.sdk.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* @author Dmitry Sheyko [17.01.2023]
*/
@Data
public class IssueJson {
private Long id;
@JsonProperty("iid")
private Long twoId;
@JsonProperty("project_id")
private Long projectId;
private String title;
private String description;
private IssueStateJson state;
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonProperty("created_at")
private LocalDateTime createdDate;
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonProperty("updated_at")
private LocalDateTime updatedDate;
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonProperty("closed_at")
private LocalDateTime closedDate;
@JsonProperty("closed_by")
private PersonJson closedBy;
private Set<String> labels;
private MilestoneJson milestone;
private List<PersonJson> assignees;
private PersonJson author;
private IssueTypeJson type;
private PersonJson assignee;
@JsonProperty("user_notes_count")
private Integer userNotesCount;
@JsonProperty("merge_requests_count")
private Integer mergeRequestsCount;
@JsonProperty("upvotes")
private Integer upVotes;
@JsonProperty("downvotes")
private Integer downVotes;
@JsonSerialize(using = LocalDateSerializer.class)
@JsonDeserialize(using = LocalDateDeserializer.class)
@JsonProperty("due_date")
private LocalDate dueDate;
private Boolean confidential;
@JsonProperty("discussion_locked")
private Integer discussionLocked;
@JsonProperty("issue_type")
private String issueType;
@JsonProperty("web_url")
private String webUrl;
@JsonProperty("time_stats")
private TimeStatsJson timeStats;
@JsonProperty("task_completion_status")
private TaskCompletionStatusJson taskCompletionStatus;
@JsonProperty("blocking_issues_count")
private Integer blockingIssuesCount;
@JsonProperty("has_tasks")
private Boolean hasTasks;
@JsonProperty("_links")
private LinksJson links;
private ReferencesJson references;
private String severity;
@JsonProperty("moved_to_id")
private Long movedToId;
@JsonProperty("service_desk_reply_to")
private Long serviceDescReplyTo;
@JsonProperty("epic_issue_id")
private Long epicId;
}

View File

@ -0,0 +1,16 @@
package dev.struchkov.bot.gitlab.sdk.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author Dmitry Sheyko [17.01.2023]
*/
public enum IssueStateJson {
@JsonProperty("opened")
OPENED,
@JsonProperty("closed")
CLOSED
}

View File

@ -0,0 +1,10 @@
package dev.struchkov.bot.gitlab.sdk.domain;
/**
* @author Dmitry Sheyko 21.01.2021
*/
public enum IssueTypeJson {
ISSUE, INCIDENT
}

View File

@ -0,0 +1,21 @@
package dev.struchkov.bot.gitlab.sdk.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* @author Dmitry Sheyko [17.01.2023]
*/
@Data
public class LinksJson {
private String self;
private String notes;
@JsonProperty("award_emoji")
private String awardEmoji;
private String project;
@JsonProperty("closed_as_duplicate_of")
private String closedAsDuplicateOf;
}

View File

@ -0,0 +1,56 @@
package dev.struchkov.bot.gitlab.sdk.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* @author Dmitry Sheyko [17.01.2023]
*/
@Data
public class MilestoneJson {
private Long id;
@JsonProperty("iid")
private Long twoId;
@JsonProperty("project_id")
private Long projectId;
private String title;
private String description;
private MilestoneStateJson state;
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonProperty("created_at")
private LocalDateTime createdDate;
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonProperty("updated_at")
private LocalDateTime updatedDate;
@JsonSerialize(using = LocalDateSerializer.class)
@JsonDeserialize(using = LocalDateDeserializer.class)
@JsonProperty("start_date")
private LocalDate startDate;
@JsonSerialize(using = LocalDateSerializer.class)
@JsonDeserialize(using = LocalDateDeserializer.class)
@JsonProperty("due_date")
private LocalDate dueDate;
private boolean expired;
@JsonProperty("web_url")
private String webUrl;
}

View File

@ -0,0 +1,16 @@
package dev.struchkov.bot.gitlab.sdk.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author Dmitry Sheyko [17.01.2023]
*/
public enum MilestoneStateJson {
Review

Лишние переносы нужно удалить во всех классах

Лишние переносы нужно удалить во всех классах
Review

Переносы удалил

Переносы удалил
@JsonProperty("active")
ACTIVE,
@JsonProperty("closed")
CLOSED
}

View File

@ -0,0 +1,21 @@
package dev.struchkov.bot.gitlab.sdk.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* @author Dmitry Sheyko [17.01.2023]
*/
@Data
public class ReferencesJson {
@JsonProperty("short")
private String shortReference;
@JsonProperty("relative")
private String relativeReference;
@JsonProperty("full")
private String fullReference;
}

View File

@ -0,0 +1,17 @@
package dev.struchkov.bot.gitlab.sdk.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* @author Dmitry Sheyko [17.01.2023]
*/
@Data
public class TaskCompletionStatusJson {
@JsonProperty("count")
private Integer count;
@JsonProperty("completed_count")
private Integer completedCount;
}

View File

@ -0,0 +1,24 @@
package dev.struchkov.bot.gitlab.sdk.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* @author Dmitry Sheyko [17.01.2023]
*/
@Data
public class TimeStatsJson {
@JsonProperty("time_estimate")
private Integer timeEstimate;
@JsonProperty("total_time_spent")
private Integer totalTimeSpent; // количество секунд затраченых на работы, пример 37800"
@JsonProperty("human_time_estimate")
private String humanTimeEstimate;
@JsonProperty("human_total_time_spent")
private String humanTotalTimeSpent; // Время строкой, пример "10h 30m"
}

View File

@ -0,0 +1,37 @@
package dev.struchkov.bot.gitlab.telegram.service.notify;
import dev.struchkov.bot.gitlab.context.domain.notify.issue.*;
import dev.struchkov.bot.gitlab.context.utils.Icons;
import dev.struchkov.godfather.simple.domain.BoxAnswer;
import org.springframework.stereotype.Component;
import static dev.struchkov.bot.gitlab.context.utils.Icons.link;
import static dev.struchkov.godfather.simple.domain.BoxAnswer.boxAnswer;
/**
* @author Dmitry Sheyko 26.01.2021
*/
@Component
public class DeleteFromAssigneesOfIssueNotifyGenerator implements NotifyBoxAnswerGenerator<DeleteFromAssigneesNotify> {
@Override
public BoxAnswer generate(DeleteFromAssigneesNotify notify) {
final StringBuilder builder = new StringBuilder(Icons.PEN)
.append(String.format(" *You excluded from %s assignees | ", notify.getIssueType()))
.append(notify.getProjectName()).append("*")
.append(Icons.HR)
.append(link(notify.getType(), notify.getUrl()))
.append(Icons.HR)
.append(notify.getUpdateDate());
final String notifyMessage = builder.toString();
return boxAnswer(notifyMessage);
}
@Override
public String getNotifyType() {
return DeleteFromAssigneesNotify.TYPE;
}
}

View File

@ -0,0 +1,39 @@
package dev.struchkov.bot.gitlab.telegram.service.notify;
import dev.struchkov.bot.gitlab.context.domain.notify.issue.DescriptionIssueNotify;
import dev.struchkov.bot.gitlab.context.utils.Icons;
import dev.struchkov.godfather.simple.domain.BoxAnswer;
import org.springframework.stereotype.Component;
import static dev.struchkov.bot.gitlab.context.utils.Icons.link;
import static dev.struchkov.godfather.simple.domain.BoxAnswer.boxAnswer;
/**
* @author Dmitry Sheyko 26.01.2021
*/
@Component
public class DescriptionIssueNotifyGenerator implements NotifyBoxAnswerGenerator<DescriptionIssueNotify> {
@Override
public BoxAnswer generate(DescriptionIssueNotify notify) {
final StringBuilder builder = new StringBuilder(Icons.PEN)
.append(String.format(" *Description of %s changed | ", notify.getIssueType()))
.append(notify.getProjectName()).append("*")
.append(Icons.HR)
.append(link(notify.getType(), notify.getUrl()))
.append(Icons.HR)
.append("new description: ")
.append(notify.getNewDescription());
final String notifyMessage = builder.toString();
return boxAnswer(notifyMessage);
}
@Override
public String getNotifyType() {
return DescriptionIssueNotify.TYPE;
}
}

View File

@ -0,0 +1,38 @@
package dev.struchkov.bot.gitlab.telegram.service.notify;
import dev.struchkov.bot.gitlab.context.domain.notify.issue.DueDateIssueNotify;
import dev.struchkov.bot.gitlab.context.utils.Icons;
import dev.struchkov.godfather.simple.domain.BoxAnswer;
import org.springframework.stereotype.Component;
import static dev.struchkov.bot.gitlab.context.utils.Icons.link;
import static dev.struchkov.godfather.simple.domain.BoxAnswer.boxAnswer;
/**
* @author Dmitry Sheyko 26.01.2021
*/
@Component
public class DueDateIssueNotifyGenerator implements NotifyBoxAnswerGenerator<DueDateIssueNotify> {
@Override
public BoxAnswer generate(DueDateIssueNotify notify) {
final StringBuilder builder = new StringBuilder(Icons.PEN)
.append(String.format(" *Due date of %s changed | ", notify.getIssueType()))
.append(notify.getProjectName()).append("*")
.append(Icons.HR)
.append(link(notify.getType(), notify.getUrl()))
.append(Icons.HR)
.append(notify.getOldDueDate()).append(Icons.ARROW).append(notify.getNewDueDate());
final String notifyMessage = builder.toString();
return boxAnswer(notifyMessage);
}
@Override
public String getNotifyType() {
return DueDateIssueNotify.TYPE;
}
}

View File

@ -0,0 +1,49 @@
package dev.struchkov.bot.gitlab.telegram.service.notify;
import dev.struchkov.bot.gitlab.context.domain.notify.issue.NewIssueNotify;
import dev.struchkov.bot.gitlab.context.utils.Icons;
import dev.struchkov.godfather.simple.domain.BoxAnswer;
import org.springframework.stereotype.Component;
import java.util.stream.Collectors;
import static dev.struchkov.bot.gitlab.context.utils.Icons.link;
import static dev.struchkov.godfather.simple.domain.BoxAnswer.boxAnswer;
import static dev.struchkov.haiti.utils.Strings.escapeMarkdown;
/**
* @author Dmitry Sheyko 24.01.2023
*/
@Component
public class NewIssueNotifyGenerator implements NotifyBoxAnswerGenerator<NewIssueNotify> {
@Override
public BoxAnswer generate(NewIssueNotify notify) {
final String labelText = notify.getLabels().stream()
.map(label -> "#" + label)
.collect(Collectors.joining(" "));
final StringBuilder builder = new StringBuilder(Icons.FUN)
.append(String.format(" *New %s assigned to you | ", notify.getIssueType()))
.append(escapeMarkdown(notify.getProjectName())).append("*")
.append(Icons.HR)
.append(link(notify.getType(), notify.getUrl()));
if (!labelText.isEmpty()) {
builder.append("\n\n").append(labelText);
}
builder.append(Icons.HR)
.append(Icons.BELL).append(": ").append(notify.getTitle()).append("\n")
.append(Icons.AUTHOR).append(": ").append(notify.getAuthor());
final String notifyMessage = builder.toString();
return boxAnswer(notifyMessage);
}
@Override
public String getNotifyType() {
return NewIssueNotify.TYPE;
}
}

View File

@ -0,0 +1,38 @@
package dev.struchkov.bot.gitlab.telegram.service.notify;
import dev.struchkov.bot.gitlab.context.domain.notify.issue.StatusIssueNotify;
import dev.struchkov.bot.gitlab.context.utils.Icons;
import dev.struchkov.godfather.simple.domain.BoxAnswer;
import org.springframework.stereotype.Component;
import static dev.struchkov.bot.gitlab.context.utils.Icons.link;
import static dev.struchkov.godfather.simple.domain.BoxAnswer.boxAnswer;
/**
* @author Dmitry Sheyko 26.01.2021
*/
@Component
public class StatusIssueNotifyGenerator implements NotifyBoxAnswerGenerator<StatusIssueNotify> {
@Override
public BoxAnswer generate(StatusIssueNotify notify) {
final StringBuilder builder = new StringBuilder(Icons.PEN)
.append(String.format(" *Status of %s changed | ", notify.getIssueType()))
.append(notify.getProjectName()).append("*")
.append(Icons.HR)
.append(link(notify.getType(), notify.getUrl()))
.append(Icons.HR)
.append(notify.getOldStatus().name()).append(Icons.ARROW).append(notify.getNewStatus().name());
final String notifyMessage = builder.toString();
return boxAnswer(notifyMessage);
}
@Override
public String getNotifyType() {
return StatusIssueNotify.TYPE;
}
}

View File

@ -0,0 +1,39 @@
package dev.struchkov.bot.gitlab.telegram.service.notify;
import dev.struchkov.bot.gitlab.context.domain.notify.issue.TitleIssueNotify;
import dev.struchkov.bot.gitlab.context.utils.Icons;
import dev.struchkov.godfather.simple.domain.BoxAnswer;
import org.springframework.stereotype.Component;
import static dev.struchkov.bot.gitlab.context.utils.Icons.link;
import static dev.struchkov.godfather.simple.domain.BoxAnswer.boxAnswer;
/**
* @author Dmitry Sheyko 26.01.2021
*/
@Component
public class TitleIssueNotifyGenerator implements NotifyBoxAnswerGenerator<TitleIssueNotify> {
@Override
public BoxAnswer generate(TitleIssueNotify notify) {
final StringBuilder builder = new StringBuilder(Icons.PEN)
.append(String.format(" *Title of %s changed | ", notify.getIssueType()))
.append(notify.getProjectName()).append("*")
.append(Icons.HR)
.append(link(notify.getType(), notify.getUrl()))
.append(Icons.HR)
.append("new title: ")
.append(notify.getNewTitle());
final String notifyMessage = builder.toString();
return boxAnswer(notifyMessage);
}
@Override
public String getNotifyType() {
return TitleIssueNotify.TYPE;
}
}

View File

@ -0,0 +1,38 @@
package dev.struchkov.bot.gitlab.telegram.service.notify;
import dev.struchkov.bot.gitlab.context.domain.notify.issue.TypeIssueNotify;
import dev.struchkov.bot.gitlab.context.utils.Icons;
import dev.struchkov.godfather.simple.domain.BoxAnswer;
import org.springframework.stereotype.Component;
import static dev.struchkov.bot.gitlab.context.utils.Icons.link;
import static dev.struchkov.godfather.simple.domain.BoxAnswer.boxAnswer;
/**
* @author Dmitry Sheyko 26.01.2021
*/
@Component
public class TypeIssueNotifyGenerator implements NotifyBoxAnswerGenerator<TypeIssueNotify> {
@Override
public BoxAnswer generate(TypeIssueNotify notify) {
final StringBuilder builder = new StringBuilder(Icons.PEN)
.append(String.format(" *Type of %s changed | ", notify.getIssueType()))
.append(notify.getProjectName()).append("*")
.append(Icons.HR)
.append(link(notify.getType(), notify.getUrl()))
.append(Icons.HR)
.append(notify.getOldType().name()).append(Icons.ARROW).append(notify.getNewType().name());
final String notifyMessage = builder.toString();
return boxAnswer(notifyMessage);
}
@Override
public String getNotifyType() {
return TypeIssueNotify.TYPE;
}
}