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

Нужно писать в одном стиле. Сейчас даже в рамках одного ПР стиль отличается. За образец предлагаю взять MergeRequestState

Нужно писать в одном стиле. Сейчас даже в рамках одного ПР стиль отличается. За образец предлагаю взять MergeRequestState

Правки внес.

Правки внес.
*/
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")

TODO можно удалить, скоре всего это просто оставлено для обратной совместимости со старым апи

TODO можно удалить, скоре всего это просто оставлено для обратной совместимости со старым апи

Удалено

Удалено
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 {

Код стайл в данном проекте это перенос строки в начале класса и в конце.
Пример

@Data
public class ReferencesJson {

    @JsonProperty("full")
    private String fullReference;
    
}
Код стайл в данном проекте это перенос строки в начале класса и в конце. Пример ``` @Data public class ReferencesJson { @JsonProperty("full") private String fullReference; } ```

Стиль исправил

Стиль исправил
@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;
}
}