From 0afdb244a2736147ede2d9bf9a84a7e5696313ab Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 14:49:23 +0300 Subject: [PATCH] milestones: per-repo milestone tracking scaffold --- milestone.v | 84 +++++++++++++++++++++++++++ milestone_routes.v | 106 +++++++++++++++++++++++++++++++++++ templates/milestone.html | 50 +++++++++++++++++ templates/milestones.html | 43 ++++++++++++++ templates/new/milestone.html | 31 ++++++++++ 5 files changed, 314 insertions(+) create mode 100644 milestone.v create mode 100644 milestone_routes.v create mode 100644 templates/milestone.html create mode 100644 templates/milestones.html create mode 100644 templates/new/milestone.html diff --git a/milestone.v b/milestone.v new file mode 100644 index 0000000..a6dbb32 --- /dev/null +++ b/milestone.v @@ -0,0 +1,84 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import time + +struct Milestone { + id int @[primary; sql: serial] +mut: + repo_id int + title string + description string + due_date int // unix seconds, 0 if not set + is_closed bool + created_at int +} + +fn (m &Milestone) status_label() string { + return if m.is_closed { 'milestone_status_closed' } else { 'milestone_status_open' } +} + +fn (m &Milestone) due_date_str() string { + if m.due_date == 0 { + return '' + } + t := time.unix(m.due_date) + return '${t.year:04d}-${t.month:02d}-${t.day:02d}' +} + +fn (mut app App) add_milestone(repo_id int, title string, description string, due_date int) !int { + m := Milestone{ + repo_id: repo_id + title: title + description: description + due_date: due_date + created_at: int(time.now().unix()) + } + sql app.db { + insert m into Milestone + }! + return db_last_insert_id(app.db) +} + +fn (mut app App) list_repo_milestones(repo_id int) []Milestone { + return sql app.db { + select from Milestone where repo_id == repo_id order by id desc + } or { []Milestone{} } +} + +fn (mut app App) find_milestone(id int) ?Milestone { + rows := sql app.db { + select from Milestone where id == id limit 1 + } or { []Milestone{} } + if rows.len == 0 { + return none + } + return rows.first() +} + +fn (mut app App) set_milestone_closed(id int, closed bool) ! { + sql app.db { + update Milestone set is_closed = closed where id == id + }! +} + +fn (mut app App) delete_milestone(id int) ! { + sql app.db { + delete from Milestone where id == id + }! +} + +fn (mut app App) delete_repo_milestones(repo_id int) ! { + sql app.db { + delete from Milestone where repo_id == repo_id + }! +} + +fn parse_yyyy_mm_dd(s string) int { + if s == '' { + return 0 + } + t := time.parse_iso8601(s + 'T00:00:00Z') or { time.parse(s) or { return 0 } } + return int(t.unix()) +} diff --git a/milestone_routes.v b/milestone_routes.v new file mode 100644 index 0000000..6474b09 --- /dev/null +++ b/milestone_routes.v @@ -0,0 +1,106 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import veb +import validation + +@['/:username/:repo_name/milestones'] +pub fn (mut app App) handle_get_repo_milestones(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public { + return ctx.not_found() + } + milestones := app.list_repo_milestones(repo.id) + return $veb.html('templates/milestones.html') +} + +@['/:username/:repo_name/milestones/new'] +pub fn (mut app App) new_milestone(mut ctx Context, username string, repo_name string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/milestones') + } + return $veb.html('templates/new/milestone.html') +} + +@['/:username/:repo_name/milestones'; post] +pub fn (mut app App) handle_create_milestone(mut ctx Context, username string, repo_name string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/milestones') + } + title := ctx.form['title'] + desc := ctx.form['description'] + due := parse_yyyy_mm_dd(ctx.form['due_date']) + if validation.is_string_empty(title) { + return ctx.redirect('/${username}/${repo_name}/milestones/new') + } + id := app.add_milestone(repo.id, title, desc, due) or { + ctx.error('Could not create milestone') + return ctx.redirect('/${username}/${repo_name}/milestones/new') + } + return ctx.redirect('/${username}/${repo_name}/milestones/${id}') +} + +@['/:username/:repo_name/milestones/:id'] +pub fn (mut app App) view_milestone(mut ctx Context, username string, repo_name string, id string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public { + return ctx.not_found() + } + milestone := app.find_milestone(id.int()) or { return ctx.not_found() } + if milestone.repo_id != repo.id { + return ctx.not_found() + } + can_edit := ctx.logged_in && repo.user_id == ctx.user.id + return $veb.html('templates/milestone.html') +} + +@['/:username/:repo_name/milestones/:id/close'; post] +pub fn (mut app App) handle_close_milestone(mut ctx Context, username string, repo_name string, id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + milestone := app.find_milestone(id.int()) or { return ctx.not_found() } + if milestone.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/milestones/${id}') + } + app.set_milestone_closed(milestone.id, true) or {} + return ctx.redirect('/${username}/${repo_name}/milestones/${id}') +} + +@['/:username/:repo_name/milestones/:id/reopen'; post] +pub fn (mut app App) handle_reopen_milestone(mut ctx Context, username string, repo_name string, id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + milestone := app.find_milestone(id.int()) or { return ctx.not_found() } + if milestone.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/milestones/${id}') + } + app.set_milestone_closed(milestone.id, false) or {} + return ctx.redirect('/${username}/${repo_name}/milestones/${id}') +} + +@['/:username/:repo_name/milestones/:id/delete'; post] +pub fn (mut app App) handle_delete_milestone(mut ctx Context, username string, repo_name string, id string) veb.Result { + if !ctx.logged_in { + return ctx.redirect_to_login() + } + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + milestone := app.find_milestone(id.int()) or { return ctx.not_found() } + if milestone.repo_id != repo.id || repo.user_id != ctx.user.id { + return ctx.redirect('/${username}/${repo_name}/milestones/${id}') + } + app.delete_milestone(milestone.id) or {} + return ctx.redirect('/${username}/${repo_name}/milestones') +} diff --git a/templates/milestone.html b/templates/milestone.html new file mode 100644 index 0000000..3259830 --- /dev/null +++ b/templates/milestone.html @@ -0,0 +1,50 @@ + + + + @include 'layout/head.html' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +
+

@milestone.title

+
+ @if milestone.is_closed + %milestone_status_closed + @else + %milestone_status_open + @end + @if milestone.due_date != 0 + %milestone_due_date @milestone.due_date_str() + @end +
+
+ +
+

@milestone.description

+
+ + @if can_edit +
+ @if milestone.is_closed +
+ +
+ @else +
+ +
+ @end +
+ +
+
+ @end +
+ + @include 'layout/footer.html' + + diff --git a/templates/milestones.html b/templates/milestones.html new file mode 100644 index 0000000..2fecef7 --- /dev/null +++ b/templates/milestones.html @@ -0,0 +1,43 @@ + + + + @include 'layout/head.html' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +
+

%milestones

+ @if ctx.logged_in && repo.user_id == ctx.user.id + %milestone_new + @end +
+ + @if milestones.len == 0 +
%milestone_none
+ @else + + @end +
+ + @include 'layout/footer.html' + + diff --git a/templates/new/milestone.html b/templates/new/milestone.html new file mode 100644 index 0000000..0c56dea --- /dev/null +++ b/templates/new/milestone.html @@ -0,0 +1,31 @@ + + + + @include '../layout/head.html' + + + @include '../layout/header.html' + +
+ @include '../layout/repo_menu.html' + +

%milestone_new

+ +
+
+ +
+
+ +
+
+ + +
+ +
+
+ + @include '../layout/footer.html' + + -- 2.39.5