diff --git a/api/src/main/java/run/halo/app/core/extension/content/Issue.java b/api/src/main/java/run/halo/app/core/extension/content/Issue.java new file mode 100644 index 000000000..16b0db9dc --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/Issue.java @@ -0,0 +1,106 @@ +package run.halo.app.core.extension.content; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import java.time.Instant; +import java.util.Objects; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.extension.GroupVersionKind; + +/** + *

Issue extension for user-reported issues/problems.

+ * + * @author halo-copilot + * @since 2.21.0 + */ +@Data +@ToString(callSuper = true) +@GVK(group = Constant.GROUP, version = Constant.VERSION, kind = Issue.KIND, + plural = "issues", singular = "issue") +@EqualsAndHashCode(callSuper = true) +public class Issue extends AbstractExtension { + + public static final String KIND = "Issue"; + + public static final GroupVersionKind GVK = GroupVersionKind.fromExtension(Issue.class); + + public static final String DELETED_LABEL = "content.halo.run/deleted"; + public static final String OWNER_LABEL = "content.halo.run/owner"; + public static final String STATUS_LABEL = "content.halo.run/status"; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private IssueSpec spec; + + @Schema + private IssueStatus status; + + public boolean isDeleted() { + return Objects.equals(true, spec.getDeleted()) + || getMetadata().getDeletionTimestamp() != null; + } + + @Data + public static class IssueSpec { + @Schema(requiredMode = RequiredMode.REQUIRED, minLength = 1) + private String title; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private String description; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private String owner; + + @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "OPEN") + private String status; + + @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "LOW") + private String priority; + + @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "BUG") + private String type; + + @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false") + private Boolean deleted; + + private Instant resolvedTime; + + private String assignee; + + private String category; + + @Data + public static class StatusEnum { + public static final String OPEN = "OPEN"; + public static final String IN_PROGRESS = "IN_PROGRESS"; + public static final String RESOLVED = "RESOLVED"; + public static final String CLOSED = "CLOSED"; + } + + @Data + public static class PriorityEnum { + public static final String LOW = "LOW"; + public static final String MEDIUM = "MEDIUM"; + public static final String HIGH = "HIGH"; + public static final String CRITICAL = "CRITICAL"; + } + + @Data + public static class TypeEnum { + public static final String BUG = "BUG"; + public static final String FEATURE_REQUEST = "FEATURE_REQUEST"; + public static final String IMPROVEMENT = "IMPROVEMENT"; + public static final String QUESTION = "QUESTION"; + } + } + + @Data + public static class IssueStatus { + private String phase; + private String observedVersion; + private Instant lastModifyTime; + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/content/IssueQuery.java b/application/src/main/java/run/halo/app/content/IssueQuery.java new file mode 100644 index 000000000..7ba9d211f --- /dev/null +++ b/application/src/main/java/run/halo/app/content/IssueQuery.java @@ -0,0 +1,119 @@ +package run.halo.app.content; + +import java.util.function.Predicate; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.server.ServerRequest; +import run.halo.app.core.extension.content.Issue; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.router.SortableRequest; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.router.selector.LabelSelector; + +/** + * Query parameters for issues. + * + * @author halo-copilot + * @since 2.21.0 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class IssueQuery extends SortableRequest { + + private final String username; + + public IssueQuery(ServerRequest request) { + this(request, null); + } + + public IssueQuery(ServerRequest request, @Nullable String username) { + super(request.exchange()); + this.username = username; + } + + @Nullable + public String getStatus() { + return queryParams.getFirst("status"); + } + + @Nullable + public String getPriority() { + return queryParams.getFirst("priority"); + } + + @Nullable + public String getType() { + return queryParams.getFirst("type"); + } + + @Nullable + public String getKeyword() { + return queryParams.getFirst("keyword"); + } + + /** + * Build predicate from query. + */ + public Predicate toPredicate() { + Predicate predicate = issue -> true; + + if (username != null) { + predicate = predicate.and(issue -> username.equals(issue.getSpec().getOwner())); + } + + if (getStatus() != null) { + predicate = predicate.and(issue -> getStatus().equals(issue.getSpec().getStatus())); + } + + if (getPriority() != null) { + predicate = predicate.and(issue -> getPriority().equals(issue.getSpec().getPriority())); + } + + if (getType() != null) { + predicate = predicate.and(issue -> getType().equals(issue.getSpec().getType())); + } + + if (getKeyword() != null && !getKeyword().trim().isEmpty()) { + predicate = predicate.and(issue -> + issue.getSpec().getTitle().toLowerCase().contains(getKeyword().toLowerCase()) || + issue.getSpec().getDescription().toLowerCase().contains(getKeyword().toLowerCase()) + ); + } + + return predicate; + } + + /** + * Convert to ListOptions for querying. + */ + public ListOptions toListOptions() { + var listOptions = new ListOptions(); + + var labelSelectorBuilder = LabelSelector.builder(); + + // Always exclude deleted issues unless specified + labelSelectorBuilder.eq(Issue.DELETED_LABEL, "false"); + + if (username != null) { + labelSelectorBuilder.eq(Issue.OWNER_LABEL, username); + } + + if (getStatus() != null) { + labelSelectorBuilder.eq(Issue.STATUS_LABEL, getStatus()); + } + + listOptions.setLabelSelector(labelSelectorBuilder.build()); + + return listOptions; + } + + /** + * Convert to PageRequest for pagination. + */ + public PageRequestImpl toPageRequest() { + return PageRequestImpl.of(getPage(), getSize(), getSort()); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/content/IssueService.java b/application/src/main/java/run/halo/app/content/IssueService.java new file mode 100644 index 000000000..0fe1fb272 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/IssueService.java @@ -0,0 +1,39 @@ +package run.halo.app.content; + +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Issue; +import run.halo.app.extension.ListResult; + +/** + * Service for {@link Issue}. + * + * @author halo-copilot + * @since 2.21.0 + */ +public interface IssueService { + + /** + * List issues by query. + */ + Mono> listIssues(IssueQuery query); + + /** + * Create issue. + */ + Mono createIssue(Issue issue); + + /** + * Update issue. + */ + Mono updateIssue(Issue issue); + + /** + * Get issue by name and owner. + */ + Mono getByUsername(String issueName, String username); + + /** + * Delete issue by name and owner. + */ + Mono deleteByUsername(String issueName, String username); +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/content/ListedIssue.java b/application/src/main/java/run/halo/app/content/ListedIssue.java new file mode 100644 index 000000000..9a4d576fa --- /dev/null +++ b/application/src/main/java/run/halo/app/content/ListedIssue.java @@ -0,0 +1,27 @@ +package run.halo.app.content; + +import lombok.Data; +import lombok.experimental.Accessors; +import run.halo.app.core.extension.content.Issue; + +/** + * Listed issue for list result. + * + * @author halo-copilot + * @since 2.21.0 + */ +@Data +@Accessors(chain = true) +public class ListedIssue { + private Issue issue; + private Stats stats; + + /** + * Issue statistic. + */ + @Data + public static class Stats { + private Integer commentsCount = 0; + private Integer upvotesCount = 0; + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/content/impl/IssueServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/IssueServiceImpl.java new file mode 100644 index 000000000..b8de1602d --- /dev/null +++ b/application/src/main/java/run/halo/app/content/impl/IssueServiceImpl.java @@ -0,0 +1,125 @@ +package run.halo.app.content.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; +import run.halo.app.content.IssueQuery; +import run.halo.app.content.IssueService; +import run.halo.app.content.ListedIssue; +import run.halo.app.core.extension.content.Issue; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.router.selector.LabelSelector; +import run.halo.app.infra.exception.NotFoundException; + +import java.time.Instant; +import java.util.Comparator; +import java.util.Objects; +import java.util.function.Function; + +/** + * Issue service implementation. + * + * @author halo-copilot + * @since 2.21.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class IssueServiceImpl implements IssueService { + + private final ReactiveExtensionClient client; + + @Override + public Mono> listIssues(IssueQuery query) { + return client.listBy(Issue.class, query.toListOptions(), query.toPageRequest()) + .flatMap(list -> Mono.fromCallable(() -> { + var listedIssues = list.get() + .map(this::toListedIssue) + .sorted(defaultComparator()) + .toList(); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), listedIssues); + })); + } + + @Override + public Mono createIssue(Issue issue) { + Assert.notNull(issue, "Issue must not be null"); + + // Set default values + if (issue.getSpec().getStatus() == null) { + issue.getSpec().setStatus("OPEN"); + } + if (issue.getSpec().getPriority() == null) { + issue.getSpec().setPriority("LOW"); + } + if (issue.getSpec().getType() == null) { + issue.getSpec().setType("BUG"); + } + if (issue.getSpec().getDeleted() == null) { + issue.getSpec().setDeleted(false); + } + + // Set metadata labels + var labels = issue.getMetadata().getLabels(); + if (labels != null) { + labels.put(Issue.DELETED_LABEL, "false"); + labels.put(Issue.OWNER_LABEL, issue.getSpec().getOwner()); + labels.put(Issue.STATUS_LABEL, issue.getSpec().getStatus()); + } + + return client.create(issue); + } + + @Override + public Mono updateIssue(Issue issue) { + Assert.notNull(issue, "Issue must not be null"); + return client.update(issue); + } + + @Override + public Mono getByUsername(String issueName, String username) { + Assert.hasText(issueName, "Issue name must not be blank"); + Assert.hasText(username, "Username must not be blank"); + + return client.get(Issue.class, issueName) + .filter(issue -> Objects.equals(issue.getSpec().getOwner(), username)) + .switchIfEmpty(Mono.error( + () -> new NotFoundException("Issue not found or access denied"))); + } + + @Override + public Mono deleteByUsername(String issueName, String username) { + return getByUsername(issueName, username) + .flatMap(issue -> { + issue.getSpec().setDeleted(true); + if (issue.getMetadata().getLabels() != null) { + issue.getMetadata().getLabels().put(Issue.DELETED_LABEL, "true"); + } + return client.update(issue); + }); + } + + private ListedIssue toListedIssue(Issue issue) { + var listedIssue = new ListedIssue() + .setIssue(issue); + + // Set stats (placeholder for now) + var stats = new ListedIssue.Stats(); + listedIssue.setStats(stats); + + return listedIssue; + } + + private Comparator defaultComparator() { + Function creationTime = issue -> + issue.getIssue().getMetadata().getCreationTimestamp(); + return Comparator.comparing(creationTime).reversed(); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/core/endpoint/uc/UcIssueEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/uc/UcIssueEndpoint.java new file mode 100644 index 000000000..561ce4135 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/endpoint/uc/UcIssueEndpoint.java @@ -0,0 +1,195 @@ +package run.halo.app.core.endpoint.uc; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; +import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.content.IssueQuery; +import run.halo.app.content.IssueService; +import run.halo.app.content.ListedIssue; +import run.halo.app.core.extension.content.Issue; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.infra.exception.NotFoundException; + +/** + * User center endpoint for issues. + * + * @author halo-copilot + * @since 2.21.0 + */ +@Component +public class UcIssueEndpoint implements CustomEndpoint { + + private final IssueService issueService; + + public UcIssueEndpoint(IssueService issueService) { + this.issueService = issueService; + } + + @Override + public RouterFunction endpoint() { + var tag = "IssueV1alpha1Uc"; + var namePathParam = parameterBuilder().name("name") + .description("Issue name") + .in(ParameterIn.PATH) + .required(true); + + return route().nest( + path("/issues"), + () -> route() + .GET(this::listMyIssues, builder -> { + builder.operationId("ListMyIssues") + .description("List issues owned by the current user.") + .tag(tag) + .response(responseBuilder().implementation( + ListResult.generateGenericClass(ListedIssue.class))); + buildParameters(builder); + } + ) + .POST(this::createMyIssue, builder -> builder.operationId("CreateMyIssue") + .tag(tag) + .description("Create my issue.") + .requestBody(requestBodyBuilder().implementation(Issue.class)) + .response(responseBuilder().implementation(Issue.class)) + ) + .GET("/{name}", this::getMyIssue, builder -> builder.operationId("GetMyIssue") + .tag(tag) + .parameter(namePathParam) + .description("Get issue that belongs to the current user.") + .response(responseBuilder().implementation(Issue.class)) + ) + .PUT("/{name}", this::updateMyIssue, builder -> + builder.operationId("UpdateMyIssue") + .tag(tag) + .parameter(namePathParam) + .description("Update my issue.") + .requestBody(requestBodyBuilder().implementation(Issue.class)) + .response(responseBuilder().implementation(Issue.class)) + ) + .DELETE("/{name}", this::deleteMyIssue, builder -> builder.tag(tag) + .operationId("DeleteMyIssue") + .description("Delete my issue.") + .parameter(namePathParam) + .response(responseBuilder().implementation(Issue.class)) + ) + .build(), + builder -> { + }) + .build(); + } + + private Mono listMyIssues(ServerRequest request) { + return getCurrentUser() + .map(username -> new IssueQuery(request, username)) + .flatMap(issueService::listIssues) + .flatMap(issues -> ServerResponse.ok().bodyValue(issues)); + } + + private Mono createMyIssue(ServerRequest request) { + return getCurrentUser() + .flatMap(username -> request.bodyToMono(Issue.class) + .doOnNext(issue -> issue.getSpec().setOwner(username)) + .flatMap(issueService::createIssue) + ) + .flatMap(issue -> ServerResponse.ok().bodyValue(issue)); + } + + private Mono getMyIssue(ServerRequest request) { + var issueName = request.pathVariable("name"); + return getCurrentUser() + .flatMap(username -> issueService.getByUsername(issueName, username)) + .flatMap(issue -> ServerResponse.ok().bodyValue(issue)); + } + + private Mono updateMyIssue(ServerRequest request) { + var issueName = request.pathVariable("name"); + return getCurrentUser() + .flatMap(username -> issueService.getByUsername(issueName, username) + .flatMap(existing -> request.bodyToMono(Issue.class) + .doOnNext(updated -> { + updated.getMetadata().setName(existing.getMetadata().getName()); + updated.getMetadata().setVersion(existing.getMetadata().getVersion()); + updated.getSpec().setOwner(username); + }) + .flatMap(issueService::updateIssue) + ) + ) + .flatMap(issue -> ServerResponse.ok().bodyValue(issue)); + } + + private Mono deleteMyIssue(ServerRequest request) { + var issueName = request.pathVariable("name"); + return getCurrentUser() + .flatMap(username -> issueService.deleteByUsername(issueName, username)) + .flatMap(issue -> ServerResponse.ok().bodyValue(issue)); + } + + private Mono getCurrentUser() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1"); + } + + private void buildParameters(org.springdoc.core.fn.builders.operation.Builder builder) { + builder.parameter(parameterBuilder() + .name("page") + .in(ParameterIn.QUERY) + .required(false) + .implementation(Integer.class) + .description("The page number. Default is 0.") + ) + .parameter(parameterBuilder() + .name("size") + .in(ParameterIn.QUERY) + .required(false) + .implementation(Integer.class) + .description("Size of each page. Default is 20.") + ) + .parameter(parameterBuilder() + .name("status") + .in(ParameterIn.QUERY) + .required(false) + .implementation(String.class) + .description("Filter by status. e.g. OPEN, IN_PROGRESS, RESOLVED, CLOSED") + ) + .parameter(parameterBuilder() + .name("priority") + .in(ParameterIn.QUERY) + .required(false) + .implementation(String.class) + .description("Filter by priority. e.g. LOW, MEDIUM, HIGH, CRITICAL") + ) + .parameter(parameterBuilder() + .name("type") + .in(ParameterIn.QUERY) + .required(false) + .implementation(String.class) + .description("Filter by type. e.g. BUG, FEATURE_REQUEST, IMPROVEMENT, QUESTION") + ) + .parameter(parameterBuilder() + .name("keyword") + .in(ParameterIn.QUERY) + .required(false) + .implementation(String.class) + .description("Filter by keyword in title or description") + ); + } +} \ No newline at end of file diff --git a/ui/src/locales/en.yaml b/ui/src/locales/en.yaml index b2dcf8490..fe0c1a89c 100644 --- a/ui/src/locales/en.yaml +++ b/ui/src/locales/en.yaml @@ -42,6 +42,7 @@ core: profile: Profile notification: Notifications posts: Posts + issues: Issues operations: console: tooltip: Console @@ -2066,6 +2067,34 @@ core: setting_modal: title: Post settings title: My posts + uc_issue: + title: My Issues + issue: + empty: + title: No issues found + message: You haven't created any issues yet, or try other filter conditions. + filters: + status: + items: + open: Open + in_progress: In Progress + resolved: Resolved + closed: Closed + status: + open: Open + in_progress: In Progress + resolved: Resolved + closed: Closed + priority: + low: Low + medium: Medium + high: High + critical: Critical + type: + bug: Bug + feature_request: Feature Request + improvement: Improvement + question: Question tool: title: Tools empty: diff --git a/ui/uc-src/modules/contents/issues/IssueList.vue b/ui/uc-src/modules/contents/issues/IssueList.vue new file mode 100644 index 000000000..f52604d9f --- /dev/null +++ b/ui/uc-src/modules/contents/issues/IssueList.vue @@ -0,0 +1,200 @@ + + + \ No newline at end of file diff --git a/ui/uc-src/modules/contents/issues/components/IssueListItem.vue b/ui/uc-src/modules/contents/issues/components/IssueListItem.vue new file mode 100644 index 000000000..1e01222e8 --- /dev/null +++ b/ui/uc-src/modules/contents/issues/components/IssueListItem.vue @@ -0,0 +1,124 @@ + + + \ No newline at end of file diff --git a/ui/uc-src/modules/contents/issues/module.ts b/ui/uc-src/modules/contents/issues/module.ts new file mode 100644 index 000000000..043fe9855 --- /dev/null +++ b/ui/uc-src/modules/contents/issues/module.ts @@ -0,0 +1,34 @@ +import { IconBookRead } from "@halo-dev/components"; +import { definePlugin } from "@halo-dev/console-shared"; +import BasicLayout from "@uc/layouts/BasicLayout.vue"; +import { markRaw } from "vue"; +import IssueList from "./IssueList.vue"; + +export default definePlugin({ + ucRoutes: [ + { + path: "/issues", + name: "IssuesRoot", + component: BasicLayout, + meta: { + title: "core.uc_issue.title", + searchable: true, + permissions: ["uc:issues:manage"], + menu: { + name: "core.uc_sidebar.menu.items.issues", + group: "content", + icon: markRaw(IconBookRead), + priority: 1, + mobile: true, + }, + }, + children: [ + { + path: "", + name: "Issues", + component: IssueList, + }, + ], + }, + ], +}); \ No newline at end of file