# HG changeset patch # User Mike Becker # Date 1722876047 -7200 # Node ID bf67e0ff71311398b81ddf9a76e553b084f323d0 # Parent bbf4eb9a71f81e2c11c122587825e9e0273d9c37 add new global issues page - fixes #404 diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/Constants.kt --- a/src/main/kotlin/de/uapcore/lightpit/Constants.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/Constants.kt Mon Aug 05 18:40:47 2024 +0200 @@ -62,11 +62,6 @@ const val REQ_ATTR_BASE_HREF = "base_href" /** - * Key for the request attribute containing the RSS feed href. - */ - const val REQ_ATTR_FEED_HREF = "feed_href" - - /** * Key for the request attribute containing the full path information (servlet path + path info). */ const val REQ_ATTR_PATH = "requestPath" diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt --- a/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt Mon Aug 05 18:40:47 2024 +0200 @@ -84,7 +84,7 @@ /** * A list of additional style sheets. - * + * TODO: remove this unnecessary attribute and merge all style sheets into one global * @see Constants#REQ_ATTR_STYLESHEET */ var styleSheets = emptyList() @@ -129,16 +129,6 @@ } } - var feedPath: String? = null - set(value) { - field = value - if (value == null) { - request.removeAttribute(Constants.REQ_ATTR_FEED_HREF) - } else { - request.setAttribute(Constants.REQ_ATTR_FEED_HREF, baseHref + value) - } - } - /** * The view object. * diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt --- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt Mon Aug 05 18:40:47 2024 +0200 @@ -73,7 +73,25 @@ fun mergeCommitRefs(refs: List) + /** + * Lists all issues. + * The result will only [includeDone] issues, if requested. + */ + fun listIssues(includeDone: Boolean): List + + /** + * Lists issues for the specified [project]. + * The result will only [includeDone] issues, if requested. + */ fun listIssues(project: Project, includeDone: Boolean): List + + /** + * Lists all issues for the specified [project]. + * The result will only [includeDone] issues, if requested. + * When a [specificVersion] or a [specificComponent] is requested, + * the result is filtered for [version] or [component] respectively. + * In both cases null means that only issues without version or component shall be returned. + */ fun listIssues( project: Project, includeDone: Boolean, @@ -97,19 +115,20 @@ fun insertIssueRelation(rel: IssueRelation) fun deleteIssueRelation(rel: IssueRelation) fun listIssueRelations(issue: Issue): List + fun getIssueRelationMap(includeDone: Boolean): IssueRelationMap fun getIssueRelationMap(project: Project, includeDone: Boolean): IssueRelationMap fun insertHistoryEvent(issue: Issue, newId: Int = 0) fun insertHistoryEvent(issue: Issue, issueComment: IssueComment, newId: Int = 0) /** - * Lists the issue history of the project with [projectId] for the past [days]. + * Lists the issue history, optionally restricted to [project], for the past [days]. */ - fun listIssueHistory(projectId: Int, days: Int): List + fun listIssueHistory(project: Project?, days: Int): List /** - * Lists the issue comment history of the project with [projectId] for the past [days]. + * Lists the issue comment history, optionally restricted to [project], for the past [days]. */ - fun listIssueCommentHistory(projectId: Int, days: Int): List + fun listIssueCommentHistory(project: Project?, days: Int): List fun listCommitRefs(issue: Issue): List } diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt --- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt Mon Aug 05 18:40:47 2024 +0200 @@ -550,6 +550,12 @@ return i } + override fun listIssues(includeDone: Boolean): List = + withStatement("$issueQuery where (? or phase < 2)") { + setBoolean(1, includeDone) + queryAll { it.extractIssue() } + } + override fun listIssues(project: Project, includeDone: Boolean): List = withStatement("$issueQuery where i.project = ? and (? or phase < 2)") { setInt(1, project.id) @@ -713,17 +719,24 @@ } override fun getIssueRelationMap(project: Project, includeDone: Boolean): IssueRelationMap = + getIssueRelationMapImpl(project, includeDone) + + override fun getIssueRelationMap(includeDone: Boolean): IssueRelationMap = + getIssueRelationMapImpl(null, includeDone) + + private fun getIssueRelationMapImpl(project: Project?, includeDone: Boolean): IssueRelationMap = withStatement( """ select r.from_issue, r.to_issue, r.type from lpit_issue_relation r join lpit_issue i on i.issueid = r.from_issue join lpit_issue_phases p on i.status = p.status - where i.project = ? and (? or p.phase < 2) + where (? or i.project = ?) and (? or p.phase < 2) """.trimIndent() ) { - setInt(1, project.id) - setBoolean(2, includeDone) + setBoolean(1, project == null) + setInt(2, project?.id ?: 0) + setBoolean(3, includeDone) queryAll { Pair(it.getInt("from_issue"), Pair(it.getInt("to_issue"), it.getEnum("type"))) } }.groupBy({it.first},{it.second}) // @@ -804,24 +817,27 @@ // - override fun listIssueHistory(projectId: Int, days: Int) = + override fun listIssueHistory(project: Project?, days: Int) = withStatement( """ - select u.username as current_assignee, evt.*, evtdata.* + select p.name as project_name, u.username as current_assignee, evt.*, evtdata.* from lpit_issue_history_event evt join lpit_issue issue using (issueid) + join lpit_project p on project = p.projectid left join lpit_user u on u.userid = issue.assignee join lpit_issue_history_data evtdata using (eventid) - where project = ? + where (? or project = ?) and time > now() - (? * interval '1' day) order by time desc """.trimIndent() ) { - setInt(1, projectId) - setInt(2, days) + setBoolean(1, project == null) + setInt(2, project?.id ?: -1) + setInt(3, days) queryAll { rs-> with(rs) { IssueHistoryEntry( + project = getString("project_name"), subject = getString("subject"), time = getTimestamp("time"), type = getEnum("type"), @@ -840,7 +856,7 @@ } } - override fun listIssueCommentHistory(projectId: Int, days: Int) = + override fun listIssueCommentHistory(project: Project?, days: Int) = withStatement( """ select u.username as current_assignee, evt.*, evtdata.* @@ -848,13 +864,14 @@ join lpit_issue issue using (issueid) left join lpit_user u on u.userid = issue.assignee join lpit_issue_comment_history evtdata using (eventid) - where project = ? + where (? or project = ?) and time > now() - (? * interval '1' day) order by time desc """.trimIndent() ) { - setInt(1, projectId) - setInt(2, days) + setBoolean(1, project == null) + setInt(2, project?.id ?: -1) + setInt(3, days) queryAll { rs-> with(rs) { IssueCommentHistoryEntry( diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/entities/Issue.kt --- a/src/main/kotlin/de/uapcore/lightpit/entities/Issue.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/entities/Issue.kt Mon Aug 05 18:40:47 2024 +0200 @@ -52,23 +52,4 @@ * An issue is overdue, if it is not done and the ETA is before the current time. */ val overdue get() = status.phase != IssueStatusPhase.Done && eta?.before(Date(System.currentTimeMillis())) ?: false - - fun hasChanged(reference: Issue) = !(component == reference.component && - status == reference.status && - category == reference.category && - subject == reference.subject && - description == reference.description && - assignee == reference.assignee && - eta == reference.eta && - affected == reference.affected && - resolved == reference.resolved) - - fun compareEtaTo(date: Date?): Int { - val eta = this.eta - return if (eta == null && date == null) 0 - else if (eta == null) 1 - else if (date == null) -1 - else eta.compareTo(date) - } } - diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/entities/IssueHistoryEntry.kt --- a/src/main/kotlin/de/uapcore/lightpit/entities/IssueHistoryEntry.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/entities/IssueHistoryEntry.kt Mon Aug 05 18:40:47 2024 +0200 @@ -32,6 +32,7 @@ import java.sql.Timestamp class IssueHistoryEntry( + val project: String, val subject: String, val time: Timestamp, val type: IssueHistoryType, diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt Mon Aug 05 18:40:47 2024 +0200 @@ -0,0 +1,223 @@ +package de.uapcore.lightpit.logic + +import de.uapcore.lightpit.HttpRequest +import de.uapcore.lightpit.dao.DataAccessObject +import de.uapcore.lightpit.dateOptValidator +import de.uapcore.lightpit.entities.Issue +import de.uapcore.lightpit.entities.IssueComment +import de.uapcore.lightpit.entities.IssueRelation +import de.uapcore.lightpit.entities.Version +import de.uapcore.lightpit.types.IssueCategory +import de.uapcore.lightpit.types.IssueStatus +import de.uapcore.lightpit.types.RelationType +import de.uapcore.lightpit.viewmodel.IssueDetailView +import de.uapcore.lightpit.viewmodel.PathInfos +import de.uapcore.lightpit.viewmodel.PathInfosFull +import de.uapcore.lightpit.viewmodel.projectNavMenu +import java.sql.Date + +fun Issue.hasChanged(reference: Issue) = !(component == reference.component && + status == reference.status && + category == reference.category && + subject == reference.subject && + description == reference.description && + assignee == reference.assignee && + eta == reference.eta && + affected == reference.affected && + resolved == reference.resolved) + +fun Issue.compareEtaTo(date: Date?): Int { + val eta = this.eta + return if (eta == null && date == null) 0 + else if (eta == null) 1 + else if (date == null) -1 + else eta.compareTo(date) +} + +fun Issue.applyFormData(http: HttpRequest, dao: DataAccessObject): Issue = this.apply { + component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1) + category = IssueCategory.valueOf(http.param("category") ?: "") + status = IssueStatus.valueOf(http.param("status") ?: "") + subject = http.param("subject") ?: "" + description = http.param("description") ?: "" + assignee = http.param("assignee")?.toIntOrNull()?.let { + when (it) { + -1 -> null + -2 -> (component?.lead ?: project.owner) + else -> dao.findUser(it) + } + } + // TODO: process error messages + eta = http.param("eta", ::dateOptValidator, null, mutableListOf()) + + affected = http.param("affected")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) } + resolved = http.param("resolved")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) } +} + +fun processIssueForm(issue: Issue, reference: Issue, http: HttpRequest, dao: DataAccessObject) { + if (issue.hasChanged(reference)) { + dao.updateIssue(issue) + dao.insertHistoryEvent(issue) + } + val newComment = http.param("comment") + if (!newComment.isNullOrBlank()) { + val comment = IssueComment(-1, issue.id).apply { + author = http.remoteUser?.let { dao.findUserByName(it) } + comment = newComment + } + val commentid = dao.insertComment(comment) + dao.insertHistoryEvent(issue, comment, commentid) + } +} + +fun commitIssueComment(http: HttpRequest, dao: DataAccessObject, pathInfos: PathInfos) { + val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) + if (issue == null) { + http.response.sendError(404) + return + } + if (processIssueComment(issue, http, dao)) { + http.renderCommit("${pathInfos.issuesHref}${issue.id}") + } +} + +fun processIssueComment(issue:Issue, http: HttpRequest, dao: DataAccessObject): Boolean { + val commentId = http.param("commentid")?.toIntOrNull() ?: -1 + if (commentId > 0) { + val comment = dao.findComment(commentId) + if (comment == null) { + http.response.sendError(404) + return false + } + val originalAuthor = comment.author?.username + if (originalAuthor != null && originalAuthor == http.remoteUser) { + val newComment = http.param("comment") + if (!newComment.isNullOrBlank()) { + comment.comment = newComment + dao.updateComment(comment) + dao.insertHistoryEvent(issue, comment) + } + } else { + http.response.sendError(403) + return false + } + } else { + val comment = IssueComment(-1, issue.id).apply { + author = http.remoteUser?.let { dao.findUserByName(it) } + comment = http.param("comment") ?: "" + } + val newId = dao.insertComment(comment) + dao.insertHistoryEvent(issue, comment, newId) + } + return true +} + +fun renderIssueView( + http: HttpRequest, + dao: DataAccessObject, + issue: Issue, + pathInfos: PathInfos, + relationError: String? = null +) { + val comments = dao.listComments(issue) + + with(http) { + pageTitle = "#${issue.id} ${issue.subject} (${issue.project.name})" + view = IssueDetailView( + issue, + comments, + dao.listIssues(issue.project, true), + dao.listIssueRelations(issue), + dao.listCommitRefs(issue), + relationError, + pathInfos + ) + if (pathInfos is PathInfosFull) { + navigationMenu = projectNavMenu(dao.listProjects(), pathInfos) + } + styleSheets = listOf("projects") + javascript = "issue-editor" + render("issue-view") + } +} + +fun addIssueRelation(http: HttpRequest, dao: DataAccessObject, pathInfos: PathInfos) { + val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) + if (issue == null) { + http.response.sendError(404) + return + } + + // determine the relation type + val type: Pair? = http.param("type")?.let { + try { + if (it.startsWith("!")) { + Pair(RelationType.valueOf(it.substring(1)), true) + } else { + Pair(RelationType.valueOf(it), false) + } + } catch (_: IllegalArgumentException) { + null + } + } + + // if the relation type was invalid, send HTTP 500 + if (type == null) { + http.response.sendError(500) + return + } + + // determine the target issue + val targetIssue: Issue? = http.param("issue")?.let { + if (it.startsWith("#") && it.length > 1) { + it.substring(1).split(" ", limit = 2)[0].toIntOrNull() + ?.let(dao::findIssue) + ?.takeIf { target -> target.project.id == issue.project.id } + } else { + null + } + } + + // check if the target issue is valid + if (targetIssue == null) { + renderIssueView(http, dao, issue, pathInfos, "issue.relations.target.invalid") + return + } + + // commit the result + dao.insertIssueRelation(IssueRelation(issue, targetIssue, type.first, type.second)) + http.renderCommit("${pathInfos.issuesHref}${issue.id}") +} + +fun removeIssueRelation(http: HttpRequest, dao: DataAccessObject, pathInfos: PathInfos) { + val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) + if (issue == null) { + http.response.sendError(404) + return + } + + // determine relation + val type = http.param("type")?.let { + try { + RelationType.valueOf(it)} + catch (_:IllegalArgumentException) {null} + } + if (type == null) { + http.response.sendError(500) + return + } + val rel = http.param("to")?.toIntOrNull()?.let(dao::findIssue)?.let { + IssueRelation( + issue, + it, + type, + http.param("reverse")?.toBoolean() ?: false + ) + } + + // execute removal, if there is something to remove + rel?.run(dao::deleteIssueRelation) + + // always pretend that the operation was successful - if there was nothing to remove, it's okay + http.renderCommit("${pathInfos.issuesHref}${issue.id}") +} \ No newline at end of file diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/servlet/FeedServlet.kt --- a/src/main/kotlin/de/uapcore/lightpit/servlet/FeedServlet.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/servlet/FeedServlet.kt Mon Aug 05 18:40:47 2024 +0200 @@ -32,6 +32,7 @@ import de.uapcore.lightpit.dao.DataAccessObject import de.uapcore.lightpit.entities.IssueCommentHistoryEntry import de.uapcore.lightpit.entities.IssueHistoryEntry +import de.uapcore.lightpit.entities.Project import de.uapcore.lightpit.types.IssueHistoryType import de.uapcore.lightpit.viewmodel.CommentDiff import de.uapcore.lightpit.viewmodel.IssueDiff @@ -79,6 +80,7 @@ private fun fullContent(issue: IssueHistoryEntry) = IssueDiff( issue.issueid, issue.subject, + issue.project, issue.component, issue.status.name, issue.category.name, @@ -162,21 +164,27 @@ } private fun issues(http: HttpRequest, dao: DataAccessObject) { - val project = http.pathParams["project"]?.let { dao.findProjectByNode(it) } - if (project == null) { - http.response.sendError(404) - return + val projectNode = http.pathParams["project"].orEmpty() + val project: Project? + if (projectNode == "-") { + project = null + } else { + project = dao.findProjectByNode(projectNode) + if (project == null) { + http.response.sendError(404) + return + } } val assignees = http.param("assignee")?.split(',') val comments = http.param("comments") ?: "all" val days = http.param("days")?.toIntOrNull() ?: 30 - val issuesFromDb = dao.listIssueHistory(project.id, days) + val issuesFromDb = dao.listIssueHistory(project, days) val issueHistory = if (assignees == null) issuesFromDb else issuesFromDb.filter { assignees.contains(it.currentAssignee) } - val commentsFromDb = dao.listIssueCommentHistory(project.id, days) + val commentsFromDb = dao.listIssueCommentHistory(project, days) val commentHistory = when (comments) { "all" -> commentsFromDb "new" -> commentsFromDb.filter { it.type == IssueHistoryType.NewComment } diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt Mon Aug 05 18:40:47 2024 +0200 @@ -0,0 +1,102 @@ +package de.uapcore.lightpit.servlet + +import de.uapcore.lightpit.AbstractServlet +import de.uapcore.lightpit.HttpRequest +import de.uapcore.lightpit.dao.DataAccessObject +import de.uapcore.lightpit.entities.Issue +import de.uapcore.lightpit.logic.* +import de.uapcore.lightpit.viewmodel.* +import jakarta.servlet.annotation.WebServlet + +@WebServlet(urlPatterns = ["/issues/*"]) +class IssuesServlet : AbstractServlet() { + + private val pathInfos = PathInfosOnlyIssues("issues/") + + init { + get("/", this::issues) + + get("/%issue", this::issue) + get("/%issue/edit", this::issueForm) + post("/%issue/comment", this::issueComment) + post("/%issue/relation", this::issueRelation) + get("/%issue/removeRelation", this::issueRemoveRelation) + post("/%issue/commit", this::issueCommit) + } + + private fun issues(http: HttpRequest, dao: DataAccessObject) { + val filter = IssueFilter(http, dao) + val needRelationsMap = filter.onlyBlocker + val relationsMap = if (needRelationsMap) dao.getIssueRelationMap(filter.includeDone) else emptyMap() + + val issues = dao.listIssues(filter.includeDone) + .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary)) + .filter(issueFilterFunction(filter, relationsMap, http.remoteUser ?: "")) + + with(http) { + pageTitle = i18n("issues") + view = IssueOverview(issues, filter) + styleSheets = listOf("projects") + javascript = "issue-overview" + render("issues") + } + } + + private fun issue(http: HttpRequest, dao: DataAccessObject) { + val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) + if (issue == null) { + http.response.sendError(404) + return + } + renderIssueView(http, dao, issue, pathInfos) + } + + private fun issueForm(http: HttpRequest, dao: DataAccessObject) { + val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) + if (issue == null) { + http.response.sendError(404) + return + } + + with(http) { + view = IssueEditView( + issue, + dao.listVersions(issue.project), + dao.listComponents(issue.project), + dao.listUsers(), + issue.project + ) + styleSheets = listOf("projects") + javascript = "issue-editor" + render("issue-form") + } + } + + private fun issueComment(http: HttpRequest, dao: DataAccessObject) { + commitIssueComment(http, dao, pathInfos) + } + + private fun issueCommit(http: HttpRequest, dao: DataAccessObject) { + val reference = http.param("id")?.toIntOrNull()?.let(dao::findIssue) + if (reference == null) { + logger.warn("Cannot create issues while not in a project context.") + http.response.sendError(404) + return + } + val issue = Issue(reference.id, reference.project) + processIssueForm(issue, reference, http, dao) + if (http.param("save") != null) { + http.renderCommit("${pathInfos.issuesHref}${issue.id}") + } else { + http.renderCommit(pathInfos.issuesHref) + } + } + + private fun issueRelation(http: HttpRequest, dao: DataAccessObject) { + addIssueRelation(http, dao, pathInfos) + } + + private fun issueRemoveRelation(http: HttpRequest, dao: DataAccessObject) { + removeIssueRelation(http, dao, pathInfos) + } +} \ No newline at end of file diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt --- a/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt Mon Aug 05 18:40:47 2024 +0200 @@ -27,8 +27,15 @@ import de.uapcore.lightpit.* import de.uapcore.lightpit.dao.DataAccessObject -import de.uapcore.lightpit.entities.* -import de.uapcore.lightpit.types.* +import de.uapcore.lightpit.entities.Component +import de.uapcore.lightpit.entities.Issue +import de.uapcore.lightpit.entities.Project +import de.uapcore.lightpit.entities.Version +import de.uapcore.lightpit.logic.* +import de.uapcore.lightpit.types.VcsType +import de.uapcore.lightpit.types.VersionStatus +import de.uapcore.lightpit.types.WebColor +import de.uapcore.lightpit.types.parseCommitRefs import de.uapcore.lightpit.viewmodel.* import jakarta.servlet.annotation.WebServlet import java.sql.Date @@ -92,13 +99,11 @@ } } - private fun feedPath(project: Project) = "feed/${project.node}/issues.rss" - private fun project(http: HttpRequest, dao: DataAccessObject) { withPathInfo(http, dao)?.let {path -> val project = path.projectInfo.project - val filter = IssueFilter(http) + val filter = IssueFilter(http, dao) val needRelationsMap = filter.onlyBlocker @@ -111,21 +116,14 @@ val issues = dao.listIssues(project, filter.includeDone, specificVersion, version, specificComponent, component) .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary)) - .filter { - (!filter.onlyMine || (it.assignee?.username ?: "") == (http.remoteUser ?: "")) && - (!filter.onlyBlocker || (relationsMap[it.id]?.any { (_,type) -> type.blocking }?:false)) && - (filter.status.isEmpty() || filter.status.contains(it.status)) && - (filter.category.isEmpty() || filter.category.contains(it.category)) && - (filter.onlyMine || filter.assignee.isEmpty() || filter.assignee.contains(it.assignee?.id ?: -1)) - } + .filter(issueFilterFunction(filter, relationsMap, http.remoteUser ?: "")) with(http) { pageTitle = project.name - view = ProjectDetails(path, issues, filter, dao.listUsers().sortedBy(User::shortDisplayname)) - feedPath = feedPath(project) + view = ProjectDetails(path, issues, filter) navigationMenu = projectNavMenu(dao.listProjects(), path) styleSheets = listOf("projects") - javascript = "project-details" + javascript = "issue-overview" render("project-details") } } @@ -196,10 +194,9 @@ path.projectInfo, dao.listVersionSummaries(path.projectInfo.project) ) - feedPath = feedPath(path.projectInfo.project) navigationMenu = projectNavMenu(dao.listProjects(), path) styleSheets = listOf("projects") - javascript = "project-details" + javascript = "issue-overview" render("versions") } } @@ -212,7 +209,6 @@ with(http) { view = VersionEditView(path.projectInfo, version) - feedPath = feedPath(path.projectInfo.project) navigationMenu = projectNavMenu(dao.listProjects(), path) styleSheets = listOf("projects") render("version-form") @@ -274,10 +270,9 @@ path.projectInfo, dao.listComponentSummaries(path.projectInfo.project) ) - feedPath = feedPath(path.projectInfo.project) navigationMenu = projectNavMenu(dao.listProjects(), path) styleSheets = listOf("projects") - javascript = "project-details" + javascript = "issue-overview" render("components") } } @@ -290,7 +285,6 @@ with(http) { view = ComponentEditView(path.projectInfo, component, dao.listUsers()) - feedPath = feedPath(path.projectInfo.project) navigationMenu = projectNavMenu(dao.listProjects(), path) styleSheets = listOf("projects") render("component-form") @@ -334,36 +328,8 @@ http.response.sendError(404) return } - renderIssueView(http, dao, issue) - } - - private fun renderIssueView( - http: HttpRequest, - dao: DataAccessObject, - issue: Issue, - relationError: String? = null - ) { - withPathInfo(http, dao)?.let {path -> - val comments = dao.listComments(issue) - - with(http) { - pageTitle = "#${issue.id} ${issue.subject} (${path.projectInfo.project.name})" - view = IssueDetailView( - path, - issue, - comments, - path.projectInfo.project, - dao.listIssues(path.projectInfo.project, true), - dao.listIssueRelations(issue), - relationError, - dao.listCommitRefs(issue) - ) - feedPath = feedPath(path.projectInfo.project) - navigationMenu = projectNavMenu(dao.listProjects(), path) - styleSheets = listOf("projects") - javascript = "issue-editor" - render("issue-view") - } + withPathInfo(http, dao)?.let { path -> + renderIssueView(http, dao, issue, path) } } @@ -400,7 +366,6 @@ path.projectInfo.project, path ) - feedPath = feedPath(path.projectInfo.project) navigationMenu = projectNavMenu(dao.listProjects(), path) styleSheets = listOf("projects") javascript = "issue-editor" @@ -410,44 +375,8 @@ } private fun issueComment(http: HttpRequest, dao: DataAccessObject) { - withPathInfo(http, dao)?.run { - val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) - if (issue == null) { - http.response.sendError(404) - return - } - - val commentId = http.param("commentid")?.toIntOrNull() ?: -1 - if (commentId > 0) { - val comment = dao.findComment(commentId) - if (comment == null) { - http.response.sendError(404) - return - } - val originalAuthor = comment.author?.username - if (originalAuthor != null && originalAuthor == http.remoteUser) { - val newComment = http.param("comment") - if (!newComment.isNullOrBlank()) { - comment.comment = newComment - dao.updateComment(comment) - dao.insertHistoryEvent(issue, comment) - } else { - logger.debug("Not updating comment ${comment.id} because nothing changed.") - } - } else { - http.response.sendError(403) - return - } - } else { - val comment = IssueComment(-1, issue.id).apply { - author = http.remoteUser?.let { dao.findUserByName(it) } - comment = http.param("comment") ?: "" - } - val newId = dao.insertComment(comment) - dao.insertHistoryEvent(issue, comment, newId) - } - - http.renderCommit("${issuesHref}${issue.id}") + withPathInfo(http, dao)?.let {path -> + commitIssueComment(http, dao, path) } } @@ -456,25 +385,7 @@ val issue = Issue( http.param("id")?.toIntOrNull() ?: -1, projectInfo.project - ).apply { - component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1) - category = IssueCategory.valueOf(http.param("category") ?: "") - status = IssueStatus.valueOf(http.param("status") ?: "") - subject = http.param("subject") ?: "" - description = http.param("description") ?: "" - assignee = http.param("assignee")?.toIntOrNull()?.let { - when (it) { - -1 -> null - -2 -> (component?.lead ?: projectInfo.project.owner) - else -> dao.findUser(it) - } - } - // TODO: process error messages - eta = http.param("eta", ::dateOptValidator, null, mutableListOf()) - - affected = http.param("affected")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) } - resolved = http.param("resolved")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) } - } + ).applyFormData(http, dao) val openId = if (issue.id < 0) { val id = dao.insertIssue(issue) @@ -486,23 +397,7 @@ http.response.sendError(404) return } - - if (issue.hasChanged(reference)) { - dao.updateIssue(issue) - dao.insertHistoryEvent(issue) - } else { - logger.debug("Not updating issue ${issue.id} because nothing changed.") - } - - val newComment = http.param("comment") - if (!newComment.isNullOrBlank()) { - val comment = IssueComment(-1, issue.id).apply { - author = http.remoteUser?.let { dao.findUserByName(it) } - comment = newComment - } - val commentid = dao.insertComment(comment) - dao.insertHistoryEvent(issue, comment, commentid) - } + processIssueForm(issue, reference, http, dao) issue.id } @@ -517,86 +412,14 @@ } private fun issueRelation(http: HttpRequest, dao: DataAccessObject) { - withPathInfo(http, dao)?.run { - val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) - if (issue == null) { - http.response.sendError(404) - return - } - - // determine the relation type - val type: Pair? = http.param("type")?.let { - try { - if (it.startsWith("!")) { - Pair(RelationType.valueOf(it.substring(1)), true) - } else { - Pair(RelationType.valueOf(it), false) - } - } catch (_: IllegalArgumentException) { - null - } - } - - // if the relation type was invalid, send HTTP 500 - if (type == null) { - http.response.sendError(500) - return - } - - // determine the target issue - val targetIssue: Issue? = http.param("issue")?.let { - if (it.startsWith("#") && it.length > 1) { - it.substring(1).split(" ", limit = 2)[0].toIntOrNull() - ?.let(dao::findIssue) - ?.takeIf { target -> target.project.id == issue.project.id } - } else { - null - } - } - - // check if the target issue is valid - if (targetIssue == null) { - renderIssueView(http, dao, issue, "issue.relations.target.invalid") - return - } - - // commit the result - dao.insertIssueRelation(IssueRelation(issue, targetIssue, type.first, type.second)) - http.renderCommit("${issuesHref}${issue.id}") + withPathInfo(http, dao)?.let {path -> + addIssueRelation(http, dao, path) } } private fun issueRemoveRelation(http: HttpRequest, dao: DataAccessObject) { - withPathInfo(http, dao)?.run { - val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) - if (issue == null) { - http.response.sendError(404) - return - } - - // determine relation - val type = http.param("type")?.let { - try {RelationType.valueOf(it)} - catch (_:IllegalArgumentException) {null} - } - if (type == null) { - http.response.sendError(500) - return - } - val rel = http.param("to")?.toIntOrNull()?.let(dao::findIssue)?.let { - IssueRelation( - issue, - it, - type, - http.param("reverse")?.toBoolean() ?: false - ) - } - - // execute removal, if there is something to remove - rel?.run(dao::deleteIssueRelation) - - // always pretend that the operation was successful - if there was nothing to remove, it's okay - http.renderCommit("${issuesHref}${issue.id}") + withPathInfo(http, dao)?.let {path -> + removeIssueRelation(http, dao, path) } } } \ No newline at end of file diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/viewmodel/Feeds.kt --- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Feeds.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Feeds.kt Mon Aug 05 18:40:47 2024 +0200 @@ -33,6 +33,7 @@ class IssueDiff( val id: Int, val currentSubject: String, + val project: String, var component: String, var status: String, var category: String, @@ -59,7 +60,7 @@ ) class IssueFeed( - val project: Project, + val project: Project?, val entries: List ) : View() { val lastModified: Timestamp = diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt --- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt Mon Aug 05 18:40:47 2024 +0200 @@ -32,7 +32,9 @@ import com.vladsch.flexmark.util.data.MutableDataSet import com.vladsch.flexmark.util.data.SharedDataKeys import de.uapcore.lightpit.HttpRequest +import de.uapcore.lightpit.dao.DataAccessObject import de.uapcore.lightpit.entities.* +import de.uapcore.lightpit.logic.compareEtaTo import de.uapcore.lightpit.types.* import kotlin.math.roundToInt @@ -101,18 +103,29 @@ data class CommitLink(val url: String, val hash: String, val message: String) +class IssueOverview( + val issues: List, + val filter: IssueFilter +) : View() { + val issueSummary = IssueSummary() + + init { + feedHref = "feed/-/issues.rss" + issues.forEach(issueSummary::add) + } +} + class IssueDetailView( - val pathInfos: PathInfos, val issue: Issue, val comments: List, - val project: Project, projectIssues: List, val currentRelations: List, + commitRefs: List, /** * Optional resource key to an error message for the relation editor. */ - val relationError: String?, - commitRefs: List + val relationError: String? = null, + val pathInfos: PathInfos? = null ) : View() { val relationTypes = RelationType.entries val linkableIssues = projectIssues.filterNot { it.id == issue.id } @@ -135,9 +148,9 @@ comment.commentFormatted = formatMarkdown(comment.comment) } - val commitBaseUrl = project.repoUrl - commitLinks = (if (commitBaseUrl == null || project.vcs == VcsType.None) emptyList() else commitRefs.map { - CommitLink(buildCommitUrl(commitBaseUrl, project.vcs, it.hash), it.hash, it.message) + val commitBaseUrl = issue.project.repoUrl + commitLinks = (if (commitBaseUrl == null || issue.project.vcs == VcsType.None) emptyList() else commitRefs.map { + CommitLink(buildCommitUrl(commitBaseUrl, issue.project.vcs, it.hash), it.hash, it.message) }) } @@ -166,8 +179,8 @@ val versions: List, val components: List, val users: List, - val project: Project, // TODO: allow null values to create issues from the IssuesServlet - val pathInfos: PathInfos + val project: Project, + val pathInfos: PathInfos? = null ) : EditView() { val versionsUpcoming: List @@ -194,10 +207,11 @@ } } -class IssueFilter(http: HttpRequest) { +class IssueFilter(http: HttpRequest, dao: DataAccessObject) { val issueStatus = IssueStatus.entries val issueCategory = IssueCategory.entries + val users = dao.listUsers().sortedBy(User::shortDisplayname) val sortCriteria = IssueSorter.Field.entries.flatMap { listOf(IssueSorter.Criteria(it, true), IssueSorter.Criteria(it, false)) } val flagIncludeDone = "f.0" val flagMine = "f.1" @@ -293,3 +307,16 @@ ?: emptyList() } } + +fun issueFilterFunction( + filter: IssueFilter, + relationsMap: IssueRelationMap, + currentUserName: String +): (issue: Issue) -> Boolean = + { + (!filter.onlyMine || (it.assignee?.username ?: "") == currentUserName) && + (!filter.onlyBlocker || (relationsMap[it.id]?.any { (_, type) -> type.blocking } ?: false)) && + (filter.status.isEmpty() || filter.status.contains(it.status)) && + (filter.category.isEmpty() || filter.category.contains(it.category)) && + (filter.onlyMine || filter.assignee.isEmpty() || filter.assignee.contains(it.assignee?.id ?: -1)) + } diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt --- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt Mon Aug 05 18:40:47 2024 +0200 @@ -56,7 +56,7 @@ }.toList() ) -fun projectNavMenu(projects: List, pathInfos: PathInfos) = NavMenu( +fun projectNavMenu(projects: List, pathInfos: PathInfosFull) = NavMenu( sequence { val cnode = pathInfos.componentInfo.node val vnode = pathInfos.versionInfo.node diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt --- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt Mon Aug 05 18:40:47 2024 +0200 @@ -31,13 +31,13 @@ import de.uapcore.lightpit.entities.Component import de.uapcore.lightpit.entities.Version -data class PathInfos( +abstract class PathInfos(val issuesHref: String) +class PathInfosOnlyIssues(issuesHref: String): PathInfos(issuesHref) +data class PathInfosFull( val projectInfo: ProjectInfo, val versionInfo: OptionalPathInfo, val componentInfo: OptionalPathInfo -) { - val issuesHref by lazyOf("projects/${projectInfo.project.node}/issues/${versionInfo.node}/${componentInfo.node}/") -} +): PathInfos("projects/${projectInfo.project.node}/issues/${versionInfo.node}/${componentInfo.node}/") private fun obtainProjectInfo(http: HttpRequest, dao: DataAccessObject): ProjectInfo? { val pathParam = http.pathParams["project"] ?: return null @@ -54,7 +54,7 @@ ) } -fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfos? { +fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfosFull? { val projectInfo = obtainProjectInfo(http, dao) if (projectInfo == null) { http.response.sendError(404) @@ -69,5 +69,5 @@ return null } - return PathInfos(projectInfo, version, component) + return PathInfosFull(projectInfo, version, component) } diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt --- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt Mon Aug 05 18:40:47 2024 +0200 @@ -46,10 +46,9 @@ ) : View() class ProjectDetails( - val pathInfos: PathInfos, + val pathInfos: PathInfosFull, val issues: List, - val filter: IssueFilter, - val users: List + val filter: IssueFilter ) : View() { val projectInfo = pathInfos.projectInfo val issueSummary = IssueSummary() @@ -57,6 +56,7 @@ val componentDetails: Component? init { + feedHref = "feed/${projectInfo.project.node}/issues.rss" issues.forEach(issueSummary::add) versionInfo = when (val vinfo = pathInfos.versionInfo){ is OptionalPathInfo.Specific -> VersionInfo(vinfo.elem, issues) diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/kotlin/de/uapcore/lightpit/viewmodel/View.kt --- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/View.kt Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/View.kt Mon Aug 05 18:40:47 2024 +0200 @@ -25,7 +25,9 @@ package de.uapcore.lightpit.viewmodel -abstract class View +abstract class View { + var feedHref = "" +} abstract class EditView : View() { var errorMessages: List = emptyList() } diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/resources/localization/strings.properties --- a/src/main/resources/localization/strings.properties Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/resources/localization/strings.properties Mon Aug 05 18:40:47 2024 +0200 @@ -137,6 +137,7 @@ language.browser = Browser language language.browser.unavailable = Browser language not available. menu.about=About +menu.issues=Issues menu.languages=Language menu.projects=Projects menu.users=Developer diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/resources/localization/strings_de.properties --- a/src/main/resources/localization/strings_de.properties Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/resources/localization/strings_de.properties Mon Aug 05 18:40:47 2024 +0200 @@ -137,6 +137,7 @@ language.browser = Browsersprache language.browser.unavailable = Browsersprache nicht verf\u00fcgbar. menu.about=Info +menu.issues=Vorg\u00e4nge menu.languages=Sprache menu.projects=Projekte menu.users=Entwickler diff -r bbf4eb9a71f8 -r bf67e0ff7131 src/main/webapp/WEB-INF/changelogs/changelog-de.jspf --- a/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf Mon Aug 05 17:41:56 2024 +0200 +++ b/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf Mon Aug 05 18:40:47 2024 +0200 @@ -27,6 +27,7 @@

Version 1.3.0 - Vorschau