From a449238961bbd5f9081831ef9db1ac936909a3e8 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Wed, 22 Apr 2026 13:44:19 +0300 Subject: [PATCH] move code from src/ to v.mod subdirs --- src/activity.v => activity.v | 0 {src => admin}/admin.v | 0 {src => admin}/admin_routes.v | 6 +- src/avatar.v => avatar.v | 0 src/avatar_test.v => avatar_test.v | 0 build.vsh | 4 +- ci/ci_routes.v | 288 +++++++++++++++++ ci/ci_status.v | 138 ++++++++ ci/ci_trigger.v | 105 ++++++ src/cli.v => cli.v | 0 {src => commit}/commit.v | 18 +- {src => commit}/commit_routes.v | 4 +- config.json | 3 +- config/loader.v | 2 + src/crypto.v => crypto.v | 0 src/feed.v => feed.v | 0 src/feed_routes.v => feed_routes.v | 0 src/github.v => github.v | 0 src/gitly.v => gitly.v | 13 +- src/issue.v => issue.v | 0 src/issue_routes.v => issue_routes.v | 0 src/main.v => main.v | 30 +- {src => repo}/branch.v | 0 {src => repo}/branch_routes.v | 2 +- {src => repo}/branch_template.v | 0 {src => repo}/comment.v | 0 {src => repo}/file.v | 10 + repo/file_routes.v | 291 +++++++++++++++++ {src => repo}/file_template.v | 0 {src => repo}/git.v | 0 {src => repo}/git_routes.v | 18 +- {src => repo}/lang_stats.v | 0 {src => repo}/release.v | 0 {src => repo}/release_routes.v | 2 +- {src => repo}/repo.v | 34 +- {src => repo}/repo_routes.v | 135 +++++--- {src => repo}/repo_template.v | 0 {src => repo}/star.v | 0 {src => repo}/tag.v | 0 {src => repo}/tag_routes.v | 0 {src => repo}/watch.v | 0 run.sh | 2 +- src/search_routes.v => search_routes.v | 0 src/security_log.v => security_log.v | 0 ...rity_log_routes.v => security_log_routes.v | 0 src/settings.v => settings.v | 0 src/static/js/tree.js | 78 ----- src/ssh_key.v => ssh_key.v | 0 src/ssh_key_routes.v => ssh_key_routes.v | 0 .../static/assets/circular_progress_bar.gif | Bin .../static/assets/default_avatar.png | Bin {src => static}/static/assets/favicon.svg | 0 static/static/assets/version | 0 {src => static}/static/css/admin.scss | 0 {src => static}/static/css/blob.scss | 0 {src => static}/static/css/branches.scss | 0 static/static/css/ci.scss | 299 ++++++++++++++++++ {src => static}/static/css/commits.scss | 0 {src => static}/static/css/contributors.scss | 0 {src => static}/static/css/feed.scss | 0 {src => static}/static/css/files.scss | 0 {src => static}/static/css/gitly.scss | 0 {src => static}/static/css/hl_table.scss | 0 {src => static}/static/css/issues.scss | 0 {src => static}/static/css/langs.scss | 0 {src => static}/static/css/releases.scss | 0 {src => static}/static/css/search.scss | 0 {src => static}/static/css/tree.scss | 23 ++ {src => static}/static/css/user.scss | 0 {src => static}/static/js/block-form.js | 0 {src => static}/static/js/footer.js | 0 {src => static}/static/js/gitly.js | 0 {src => static}/static/js/ssh-list.js | 0 static/static/js/tree.js | 143 +++++++++ {src => static}/static/js/user.js | 0 {src => static}/static/js/users.js | 0 {src => static}/static/robots.txt | 0 .../admin/settings.html | 0 .../admin/statistics.html | 0 {src/templates => templates}/admin/users.html | 0 {src/templates => templates}/blob.html | 4 + {src/templates => templates}/branches.html | 0 templates/ci_run_detail.html | 57 ++++ templates/ci_runs.html | 66 ++++ .../cloning_in_process.html | 0 {src/templates => templates}/commit.html | 0 {src/templates => templates}/commits.html | 0 .../templates => templates}/contributors.html | 0 templates/edit_file.html | 38 +++ {src/templates => templates}/index.html | 0 {src/templates => templates}/issue.html | 0 {src/templates => templates}/issues.html | 0 .../layout/footer.html | 0 {src/templates => templates}/layout/head.html | 0 .../layout/header.html | 0 .../layout/repo_menu.html | 4 + .../layout/tree_path.html | 0 {src/templates => templates}/login.html | 0 {src/templates => templates}/new.html | 0 {src/templates => templates}/new/issue.html | 0 templates/new_file.html | 38 +++ {src/templates => templates}/register.html | 0 {src/templates => templates}/releases.html | 0 .../repo/settings.html | 0 {src/templates => templates}/search.html | 0 {src/templates => templates}/security.html | 0 {src/templates => templates}/svgs/star.html | 0 {src/templates => templates}/svgs/unstar.html | 0 .../templates => templates}/svgs/unwatch.html | 0 {src/templates => templates}/svgs/watch.html | 0 {src/templates => templates}/tree.html | 25 +- {src/templates => templates}/user.html | 0 {src/templates => templates}/user/feed.html | 0 {src/templates => templates}/user/issues.html | 0 {src/templates => templates}/user/repos.html | 0 .../user/settings.html | 0 .../user/ssh/keys/list.html | 0 .../user/ssh/keys/new.html | 0 {src/templates => templates}/user/stars.html | 0 src/token.v => token.v | 0 src/user.v => user.v | 8 +- src/user_routes.v => user_routes.v | 0 src/utils.v => utils.v | 0 v.mod | 1 + 124 files changed, 1701 insertions(+), 188 deletions(-) rename src/activity.v => activity.v (100%) rename {src => admin}/admin.v (100%) rename {src => admin}/admin_routes.v (91%) rename src/avatar.v => avatar.v (100%) rename src/avatar_test.v => avatar_test.v (100%) create mode 100644 ci/ci_routes.v create mode 100644 ci/ci_status.v create mode 100644 ci/ci_trigger.v rename src/cli.v => cli.v (100%) rename {src => commit}/commit.v (88%) rename {src => commit}/commit_routes.v (96%) rename src/crypto.v => crypto.v (100%) rename src/feed.v => feed.v (100%) rename src/feed_routes.v => feed_routes.v (100%) rename src/github.v => github.v (100%) rename src/gitly.v => gitly.v (94%) rename src/issue.v => issue.v (100%) rename src/issue_routes.v => issue_routes.v (100%) rename src/main.v => main.v (62%) rename {src => repo}/branch.v (100%) rename {src => repo}/branch_routes.v (94%) rename {src => repo}/branch_template.v (100%) rename {src => repo}/comment.v (100%) rename {src => repo}/file.v (95%) create mode 100644 repo/file_routes.v rename {src => repo}/file_template.v (100%) rename {src => repo}/git.v (100%) rename {src => repo}/git_routes.v (94%) rename {src => repo}/lang_stats.v (100%) rename {src => repo}/release.v (100%) rename {src => repo}/release_routes.v (96%) rename {src => repo}/repo.v (96%) rename {src => repo}/repo_routes.v (83%) rename {src => repo}/repo_template.v (100%) rename {src => repo}/star.v (100%) rename {src => repo}/tag.v (100%) rename {src => repo}/tag_routes.v (100%) rename {src => repo}/watch.v (100%) rename src/search_routes.v => search_routes.v (100%) rename src/security_log.v => security_log.v (100%) rename src/security_log_routes.v => security_log_routes.v (100%) rename src/settings.v => settings.v (100%) delete mode 100644 src/static/js/tree.js rename src/ssh_key.v => ssh_key.v (100%) rename src/ssh_key_routes.v => ssh_key_routes.v (100%) rename {src => static}/static/assets/circular_progress_bar.gif (100%) rename {src => static}/static/assets/default_avatar.png (100%) rename {src => static}/static/assets/favicon.svg (100%) create mode 100644 static/static/assets/version rename {src => static}/static/css/admin.scss (100%) rename {src => static}/static/css/blob.scss (100%) rename {src => static}/static/css/branches.scss (100%) create mode 100644 static/static/css/ci.scss rename {src => static}/static/css/commits.scss (100%) rename {src => static}/static/css/contributors.scss (100%) rename {src => static}/static/css/feed.scss (100%) rename {src => static}/static/css/files.scss (100%) rename {src => static}/static/css/gitly.scss (100%) rename {src => static}/static/css/hl_table.scss (100%) rename {src => static}/static/css/issues.scss (100%) rename {src => static}/static/css/langs.scss (100%) rename {src => static}/static/css/releases.scss (100%) rename {src => static}/static/css/search.scss (100%) rename {src => static}/static/css/tree.scss (92%) rename {src => static}/static/css/user.scss (100%) rename {src => static}/static/js/block-form.js (100%) rename {src => static}/static/js/footer.js (100%) rename {src => static}/static/js/gitly.js (100%) rename {src => static}/static/js/ssh-list.js (100%) create mode 100644 static/static/js/tree.js rename {src => static}/static/js/user.js (100%) rename {src => static}/static/js/users.js (100%) rename {src => static}/static/robots.txt (100%) rename {src/templates => templates}/admin/settings.html (100%) rename {src/templates => templates}/admin/statistics.html (100%) rename {src/templates => templates}/admin/users.html (100%) rename {src/templates => templates}/blob.html (84%) rename {src/templates => templates}/branches.html (100%) create mode 100644 templates/ci_run_detail.html create mode 100644 templates/ci_runs.html rename {src/templates => templates}/cloning_in_process.html (100%) rename {src/templates => templates}/commit.html (100%) rename {src/templates => templates}/commits.html (100%) rename {src/templates => templates}/contributors.html (100%) create mode 100644 templates/edit_file.html rename {src/templates => templates}/index.html (100%) rename {src/templates => templates}/issue.html (100%) rename {src/templates => templates}/issues.html (100%) rename {src/templates => templates}/layout/footer.html (100%) rename {src/templates => templates}/layout/head.html (100%) rename {src/templates => templates}/layout/header.html (100%) rename {src/templates => templates}/layout/repo_menu.html (88%) rename {src/templates => templates}/layout/tree_path.html (100%) rename {src/templates => templates}/login.html (100%) rename {src/templates => templates}/new.html (100%) rename {src/templates => templates}/new/issue.html (100%) create mode 100644 templates/new_file.html rename {src/templates => templates}/register.html (100%) rename {src/templates => templates}/releases.html (100%) rename {src/templates => templates}/repo/settings.html (100%) rename {src/templates => templates}/search.html (100%) rename {src/templates => templates}/security.html (100%) rename {src/templates => templates}/svgs/star.html (100%) rename {src/templates => templates}/svgs/unstar.html (100%) rename {src/templates => templates}/svgs/unwatch.html (100%) rename {src/templates => templates}/svgs/watch.html (100%) rename {src/templates => templates}/tree.html (89%) rename {src/templates => templates}/user.html (100%) rename {src/templates => templates}/user/feed.html (100%) rename {src/templates => templates}/user/issues.html (100%) rename {src/templates => templates}/user/repos.html (100%) rename {src/templates => templates}/user/settings.html (100%) rename {src/templates => templates}/user/ssh/keys/list.html (100%) rename {src/templates => templates}/user/ssh/keys/new.html (100%) rename {src/templates => templates}/user/stars.html (100%) rename src/token.v => token.v (100%) rename src/user.v => user.v (98%) rename src/user_routes.v => user_routes.v (100%) rename src/utils.v => utils.v (100%) diff --git a/src/activity.v b/activity.v similarity index 100% rename from src/activity.v rename to activity.v diff --git a/src/admin.v b/admin/admin.v similarity index 100% rename from src/admin.v rename to admin/admin.v diff --git a/src/admin_routes.v b/admin/admin_routes.v similarity index 91% rename from src/admin_routes.v rename to admin/admin_routes.v index 86d5811..dd858dc 100644 --- a/src/admin_routes.v +++ b/admin/admin_routes.v @@ -12,7 +12,7 @@ pub fn (mut app App) admin_settings(mut ctx Context) veb.Result { return ctx.redirect_to_index() } - return $veb.html() + return $veb.html('../templates/admin/settings.html') } @['/admin/settings'; post] @@ -60,7 +60,7 @@ pub fn (mut app App) admin_users(mut ctx Context, page int) veb.Result { is_last_page := check_last_page(user_count, offset, admin_users_per_page) prev_page, next_page := generate_prev_next_pages(page) - return $veb.html() + return $veb.html('../templates/admin/users.html') } @['/admin/statistics'] @@ -68,5 +68,5 @@ pub fn (mut app App) admin_statistics() veb.Result { if !ctx.is_admin() { return ctx.redirect_to_index() } - return $veb.html() + return $veb.html('../templates/admin/statistics.html') } diff --git a/src/avatar.v b/avatar.v similarity index 100% rename from src/avatar.v rename to avatar.v diff --git a/src/avatar_test.v b/avatar_test.v similarity index 100% rename from src/avatar_test.v rename to avatar_test.v diff --git a/build.vsh b/build.vsh index 55fcf03..4b6ad85 100644 --- a/build.vsh +++ b/build.vsh @@ -1,8 +1,8 @@ import net.http -path := 'src/static/css/gitly.css' +path := 'static/css/gitly.css' if !exists(path) { - ret := system('sassc src/static/css/gitly.scss > src/static/css/gitly.css') + ret := system('sassc static/css/gitly.scss > static/css/gitly.css') if ret != 0 { http.download_file('https://gitly.org/css/gitly.css', path)! println("No sassc detected on this system, gitly.css has been downloaded from gitly.org.") diff --git a/ci/ci_routes.v b/ci/ci_routes.v new file mode 100644 index 0000000..2e8020a --- /dev/null +++ b/ci/ci_routes.v @@ -0,0 +1,288 @@ +module main + +import veb +import json +import net.http +import os +import time + +struct CiStatusCallback { + run_id string + repo_id string + commit_hash string + branch string + status string +} + +// POST /api/v1/ci/status - Callback endpoint for gitly_ci to report status updates +@['/api/v1/ci/status'; post] +pub fn (mut app App) handle_ci_status_callback() veb.Result { + body := ctx.req.data + callback := json.decode(CiStatusCallback, body) or { + return ctx.json_error('Invalid request body') + } + + repo_id := callback.repo_id.int() + ci_run_id := callback.run_id.int() + status := ci_status_from_string(callback.status) + + app.upsert_ci_status(repo_id, callback.commit_hash, callback.branch, status, ci_run_id) or { + return ctx.json_error('Failed to update CI status: ${err}') + } + + return ctx.json_success('ok') +} + +// GET /:username/:repo_name/ci - CI runs list page +@['/:username/:repo_name/ci'] +pub fn (mut app App) ci_runs(username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + + if !repo.is_public { + if repo.user_id != ctx.user.id { + return ctx.not_found() + } + } + + // Check if .gitly-ci.yml exists in the repo + has_ci_file := os.execute('git -C ${repo.git_dir} show ${repo.primary_branch}:.gitly-ci.yml').exit_code == 0 + + // Fetch runs from gitly_ci service for a complete list + mut ci_runs := []CiRunListItem{} + mut ci_service_error := false + if app.config.ci_service_url != '' { + runs_url := '${app.config.ci_service_url}/api/v1/runs/repo/${repo.id}' + response := http.get(runs_url) or { + ci_service_error = true + http.Response{} + } + if !ci_service_error && response.status_code == 200 { + runs_resp := json.decode(CiApiRunListResponse, response.body) or { + CiApiRunListResponse{} + } + if runs_resp.success { + for r in runs_resp.result { + ci_runs << CiRunListItem{ + ci_run_id: r.id + status: ci_status_from_string(r.status) + commit_hash: r.commit_hash + branch: r.branch + created_at: r.created_at + finished_at: r.finished_at + } + } + } + } else if !ci_service_error && response.status_code != 200 { + ci_service_error = true + } + } + + return $veb.html('../templates/ci_runs.html') +} + +// GET /:username/:repo_name/ci/:run_id_str - CI run detail page +@['/:username/:repo_name/ci/:run_id_str'] +pub fn (mut app App) ci_run_detail(username string, repo_name string, run_id_str string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + + if !repo.is_public { + if repo.user_id != ctx.user.id { + return ctx.not_found() + } + } + + ci_run_id := run_id_str.int() + + // Fetch run details from gitly_ci service + if app.config.ci_service_url == '' { + return ctx.not_found() + } + + ci_url := '${app.config.ci_service_url}/api/v1/runs/${ci_run_id}' + response := http.get(ci_url) or { + return ctx.not_found() + } + + if response.status_code != 200 { + return ctx.not_found() + } + + ci_run_json := response.body + + // Parse the response to display + run_data := json.decode(CiApiRunResponse, ci_run_json) or { + return ctx.not_found() + } + + ci_run := run_data.result + + return $veb.html('../templates/ci_run_detail.html') +} + +// POST /:username/:repo_name/ci/:run_id_str/restart - Restart a CI run +@['/:username/:repo_name/ci/:run_id_str/restart'; post] +pub fn (mut app App) ci_restart_run(username string, repo_name string, run_id_str string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + + // Only repo owner can restart + if repo.user_id != ctx.user.id { + return ctx.not_found() + } + + ci_run_id := run_id_str.int() + + if app.config.ci_service_url == '' { + return ctx.not_found() + } + + // Call gitly_ci restart API + restart_url := '${app.config.ci_service_url}/api/v1/runs/${ci_run_id}/restart' + response := http.post(restart_url, '') or { + return ctx.not_found() + } + + if response.status_code != 200 { + return ctx.not_found() + } + + result := json.decode(CiApiRunResponse, response.body) or { + return ctx.not_found() + } + + if result.success { + new_run := result.result + // Update local CI status + app.upsert_ci_status(repo.id, new_run.commit_hash, new_run.branch, .pending, new_run.id) or {} + // Redirect to new run + return ctx.redirect('/${username}/${repo_name}/ci/${new_run.id}') + } + + return ctx.redirect('/${username}/${repo_name}/ci/${ci_run_id}') +} + +// Structs for parsing gitly_ci API responses + +struct CiApiRunListResponse { + success bool + result []CiRunListResponseItem +} + +struct CiRunListResponseItem { + id int + status string + commit_hash string + branch string + created_at int + finished_at int +} + +struct CiRunListItem { + ci_run_id int + status CiStatusEnum + commit_hash string + branch string + created_at int + finished_at int +} + +fn (ci &CiRunListItem) relative_time() string { + if ci.finished_at > 0 { + return time.unix(ci.finished_at).relative() + } + if ci.created_at > 0 { + return time.unix(ci.created_at).relative() + } + return '' +} + +struct CiApiRunResponse { + success bool + result CiRunDetail +} + +struct CiRunDetail { + id int + status string + commit_hash string + branch string + created_at int + finished_at int + jobs []CiJobDetail +} + +struct CiJobDetail { + id int + name string + status string + exit_code int + started_at int + finished_at int + steps []CiStepDetail +} + +struct CiStepDetail { + id int + name string + command string + status string + output string + exit_code int +} + +fn (r &CiRunDetail) status_css_class() string { + return match r.status { + 'success' { 'ci-success' } + 'failure' { 'ci-failure' } + 'running' { 'ci-running' } + 'cancelled' { 'ci-cancelled' } + else { 'ci-pending' } + } +} + +fn (r &CiRunDetail) created_relative() string { + if r.created_at == 0 { + return '' + } + return time.unix(r.created_at).relative() +} + +fn (r &CiRunDetail) duration() string { + if r.finished_at == 0 || r.created_at == 0 { + return 'running...' + } + d := r.finished_at - r.created_at + if d < 60 { + return '${d}s' + } + return '${d / 60}m ${d % 60}s' +} + +fn (j &CiJobDetail) status_css_class() string { + return match j.status { + 'success' { 'ci-success' } + 'failure' { 'ci-failure' } + 'running' { 'ci-running' } + 'cancelled' { 'ci-cancelled' } + else { 'ci-pending' } + } +} + +fn (s &CiStepDetail) status_css_class() string { + return match s.status { + 'success' { 'ci-success' } + 'failure' { 'ci-failure' } + 'running' { 'ci-running' } + 'cancelled' { 'ci-cancelled' } + else { 'ci-pending' } + } +} + +fn (s &CiStepDetail) status_icon() string { + return match s.status { + 'success' { '✓' } + 'failure' { '✗' } + 'running' { '⟳' } + 'cancelled' { '⊘' } + else { '○' } + } +} diff --git a/ci/ci_status.v b/ci/ci_status.v new file mode 100644 index 0000000..1595e09 --- /dev/null +++ b/ci/ci_status.v @@ -0,0 +1,138 @@ +module main + +import time + +enum CiStatusEnum { + pending = 0 + running = 1 + success = 2 + failure = 3 + cancelled = 4 +} + +fn (s CiStatusEnum) str() string { + return match s { + .pending { 'pending' } + .running { 'running' } + .success { 'success' } + .failure { 'failure' } + .cancelled { 'cancelled' } + } +} + +fn (s CiStatusEnum) css_class() string { + return match s { + .pending { 'ci-pending' } + .running { 'ci-running' } + .success { 'ci-success' } + .failure { 'ci-failure' } + .cancelled { 'ci-cancelled' } + } +} + +fn (s CiStatusEnum) icon() string { + return match s { + .pending { '⏳' } + .running { '🔄' } + .success { '✓' } + .failure { '✗' } + .cancelled { '⊘' } + } +} + +struct CiStatus { + id int @[primary; sql: serial] + repo_id int + commit_hash string + branch string + status CiStatusEnum + ci_run_id int + created_at int + updated_at int +} + +fn ci_status_from_string(s string) CiStatusEnum { + return match s { + 'pending' { CiStatusEnum.pending } + 'running' { CiStatusEnum.running } + 'success' { CiStatusEnum.success } + 'failure' { CiStatusEnum.failure } + 'cancelled' { CiStatusEnum.cancelled } + else { CiStatusEnum.pending } + } +} + +fn (mut app App) find_ci_status_for_commit(repo_id int, commit_hash string) ?CiStatus { + results := sql app.db { + select from CiStatus where repo_id == repo_id && commit_hash == commit_hash order by id desc limit 1 + } or { return none } + if results.len == 0 { + return none + } + return results[0] +} + +fn (mut app App) find_ci_status_for_branch(repo_id int, branch string) ?CiStatus { + results := sql app.db { + select from CiStatus where repo_id == repo_id && branch == branch order by id desc limit 1 + } or { return none } + if results.len == 0 { + return none + } + return results[0] +} + +fn (mut app App) find_ci_runs_for_repo(repo_id int) []CiStatus { + return sql app.db { + select from CiStatus where repo_id == repo_id order by id desc + } or { []CiStatus{} } +} + +fn (mut app App) add_ci_status(ci CiStatus) ! { + sql app.db { + insert ci into CiStatus + }! +} + +fn (mut app App) update_ci_status(repo_id int, commit_hash string, status CiStatusEnum) ! { + updated := int(time.now().unix()) + sql app.db { + update CiStatus set status = status, updated_at = updated where repo_id == repo_id && commit_hash == commit_hash + }! +} + +fn (mut app App) upsert_ci_status(repo_id int, commit_hash string, branch string, status CiStatusEnum, ci_run_id int) ! { + existing := app.find_ci_status_for_commit(repo_id, commit_hash) or { + // Insert new + app.add_ci_status(CiStatus{ + repo_id: repo_id + commit_hash: commit_hash + branch: branch + status: status + ci_run_id: ci_run_id + created_at: int(time.now().unix()) + updated_at: int(time.now().unix()) + })! + return + } + // Update existing + id := existing.id + updated := int(time.now().unix()) + sql app.db { + update CiStatus set status = status, ci_run_id = ci_run_id, updated_at = updated where id == id + }! +} + +fn (mut app App) delete_repo_ci_statuses(repo_id int) ! { + sql app.db { + delete from CiStatus where repo_id == repo_id + }! +} + +fn (ci &CiStatus) relative_time() string { + if ci.updated_at == 0 && ci.created_at == 0 { + return '' + } + t := if ci.updated_at > 0 { ci.updated_at } else { ci.created_at } + return time.unix(t).relative() +} diff --git a/ci/ci_trigger.v b/ci/ci_trigger.v new file mode 100644 index 0000000..0733282 --- /dev/null +++ b/ci/ci_trigger.v @@ -0,0 +1,105 @@ +module main + +import json +import net.http +import os + +struct CiTriggerPayload { + repo_id int + commit_hash string + branch string + repo_path string + yaml_config string + callback_url string +} + +struct CiTriggerResponse { + success bool + result CiTriggerResult +} + +struct CiTriggerResult { + id int + status string +} + +// trigger_ci_if_configured checks if the repo has a .gitly-ci.yml and triggers a CI run +fn (mut app App) trigger_ci_if_configured(repo_id int, branch_name string) { + repo := app.find_repo_by_id(repo_id) or { return } + + if app.config.ci_service_url == '' { + return + } + + // Read .gitly-ci.yml from the repo using git show (works with bare repos) + show_result := os.execute('git -C ${repo.git_dir} show ${branch_name}:.gitly-ci.yml') + if show_result.exit_code != 0 || show_result.output.trim_space() == '' { + app.info('No .gitly-ci.yml found in ${repo.name}/${branch_name}') + return + } + yaml_config := show_result.output + + app.info('Found .gitly-ci.yml in ${repo.name}/${branch_name}, triggering CI') + app.send_ci_trigger(repo, branch_name, yaml_config) +} + +// trigger_ci_with_config triggers CI with a known YAML config (e.g. when the file was just created via web UI) +fn (mut app App) trigger_ci_with_config(repo_id int, branch_name string, yaml_config string) { + repo := app.find_repo_by_id(repo_id) or { return } + + if app.config.ci_service_url == '' { + return + } + + app.info('Triggering CI for ${repo.name}/${branch_name} with provided config') + app.send_ci_trigger(repo, branch_name, yaml_config) +} + +fn (mut app App) send_ci_trigger(repo Repo, branch_name string, yaml_config string) { + // Get the latest commit hash for this branch + commit_hash := repo.get_last_branch_commit_hash(branch_name) + + // Build callback URL + callback_url := 'http://localhost:${app.port}/api/v1/ci/status' + + // Get the absolute path to the git directory + repo_path := os.real_path(repo.git_dir) + + payload := json.encode(CiTriggerPayload{ + repo_id: repo.id + commit_hash: commit_hash + branch: branch_name + repo_path: repo_path + yaml_config: yaml_config + callback_url: callback_url + }) + + // Record pending status + app.upsert_ci_status(repo.id, commit_hash, branch_name, .pending, 0) or { + app.warn('Failed to create CI status: ${err}') + } + + // Trigger CI service + ci_url := '${app.config.ci_service_url}/api/v1/trigger' + app.info('Posting CI trigger to ${ci_url}') + + response := http.post_json(ci_url, payload) or { + app.warn('Failed to trigger CI: ${err}') + return + } + + if response.status_code == 200 { + result := json.decode(CiTriggerResponse, response.body) or { + app.warn('Failed to parse CI trigger response') + return + } + if result.success { + app.upsert_ci_status(repo.id, commit_hash, branch_name, .pending, result.result.id) or { + app.warn('Failed to update CI status with run id') + } + app.info('CI run ${result.result.id} triggered for ${repo.name}') + } + } else { + app.warn('CI trigger returned status ${response.status_code}: ${response.body}') + } +} diff --git a/src/cli.v b/cli.v similarity index 100% rename from src/cli.v rename to cli.v diff --git a/src/commit.v b/commit/commit.v similarity index 88% rename from src/commit.v rename to commit/commit.v index e40e36c..29ea655 100644 --- a/src/commit.v +++ b/commit/commit.v @@ -81,17 +81,14 @@ fn (commit Commit) get_changes(repo Repo) []Change { return changes } -fn (mut app App) add_commit_if_not_exist(repo_id int, branch_id int, last_hash string, author string, author_id int, message string, date int) ! { - commits := sql app.db { - select from Commit where repo_id == repo_id && branch_id == branch_id && hash == last_hash limit 1 - } or { []Commit{} } - - // $dbg; - - if commits.len > 0 { - return - } +fn (mut app App) commit_exists(repo_id int, branch_id int, hash string) bool { + count := sql app.db { + select count from Commit where repo_id == repo_id && branch_id == branch_id && hash == hash + } or { 0 } + return count > 0 +} +fn (mut app App) add_commit(repo_id int, branch_id int, last_hash string, author string, author_id int, message string, date int) ! { new_commit := Commit{ author_id: author_id author: author @@ -102,7 +99,6 @@ fn (mut app App) add_commit_if_not_exist(repo_id int, branch_id int, last_hash s message: message } - // $dbg; sql app.db { insert new_commit into Commit }! diff --git a/src/commit_routes.v b/commit/commit_routes.v similarity index 96% rename from src/commit_routes.v rename to commit/commit_routes.v index d13a20f..e8ce255 100644 --- a/src/commit_routes.v +++ b/commit/commit_routes.v @@ -71,7 +71,7 @@ pub fn (mut app App) commits(mut ctx Context, username string, repo_name string, } } - return $veb.html() + return $veb.html('../templates/commits.html') } @['/:username/:repo_name/commit/:hash'] @@ -101,5 +101,5 @@ pub fn (mut app App) commit(mut ctx Context, username string, repo_name string, sources[change.file] = veb.RawHtml(src) } - return $veb.html() + return $veb.html('../templates/commit.html') } diff --git a/config.json b/config.json index 1b31616..cdd0719 100644 --- a/config.json +++ b/config.json @@ -2,5 +2,6 @@ "repo_storage_path": "./repos", "archive_path": "./archives", "avatars_path": "./avatars", - "hostname": "gitly.org" + "hostname": "gitly.org", + "ci_service_url": "http://localhost:8081" } diff --git a/config/loader.v b/config/loader.v index ab0ccd5..63b4e57 100644 --- a/config/loader.v +++ b/config/loader.v @@ -9,6 +9,8 @@ pub: archive_path string avatars_path string hostname string + ci_service_url string + port int } pub fn read_config(path string) !Config { diff --git a/src/crypto.v b/crypto.v similarity index 100% rename from src/crypto.v rename to crypto.v diff --git a/src/feed.v b/feed.v similarity index 100% rename from src/feed.v rename to feed.v diff --git a/src/feed_routes.v b/feed_routes.v similarity index 100% rename from src/feed_routes.v rename to feed_routes.v diff --git a/src/github.v b/github.v similarity index 100% rename from src/github.v rename to github.v diff --git a/src/gitly.v b/gitly.v similarity index 94% rename from src/gitly.v rename to gitly.v index 3ec778a..1291dc7 100644 --- a/src/gitly.v +++ b/gitly.v @@ -33,6 +33,7 @@ mut: logger log.Log config config.Config settings Settings + port int } pub struct Context { @@ -74,7 +75,7 @@ fn new_app() !&App { app.setup_logger() - mut version := os.read_file('src/static/assets/version') or { 'unknown' } + mut version := os.read_file('static/assets/version') or { 'unknown' } git_result := os.execute('git rev-parse --short HEAD') if git_result.exit_code == 0 && !git_result.output.contains('fatal') { @@ -82,12 +83,12 @@ fn new_app() !&App { } if version != app.version { - os.write_file('src/static/assets/version', app.version) or { panic(err) } + os.write_file('static/assets/version', app.version) or { panic(err) } } app.version = version - app.handle_static('src/static', true)! + app.handle_static('static', true)! if !os.exists('avatars') { os.mkdir('avatars')! } @@ -141,7 +142,7 @@ pub fn (mut app App) debug(msg string) { pub fn (mut app App) init_server() { } -pub fn (mut app App) before_request(mut ctx Context) { +pub fn (mut app App) before_request(mut ctx Context) bool { url := ctx.req.url ctx.logged_in = app.is_logged_in(mut ctx) app.load_settings() // TODO no need in doing this for each request @@ -153,6 +154,7 @@ pub fn (mut app App) before_request(mut ctx Context) { } dump(url) ctx.lang = Lang.from_string(ctx.get_cookie('lang') or { 'en' }) or { Lang.en } + return true } @['/'] @@ -249,6 +251,9 @@ fn (mut app App) create_tables() ! { sql app.db { create table Watch }! + sql app.db { + create table CiStatus + }! } fn (mut ctx Context) json_success[T](result T) veb.Result { diff --git a/src/issue.v b/issue.v similarity index 100% rename from src/issue.v rename to issue.v diff --git a/src/issue_routes.v b/issue_routes.v similarity index 100% rename from src/issue_routes.v rename to issue_routes.v diff --git a/src/main.v b/main.v similarity index 62% rename from src/main.v rename to main.v index 7ce0b1e..c0fa890 100644 --- a/src/main.v +++ b/main.v @@ -1,17 +1,29 @@ import os import veb - -const http_port = get_port() - -fn get_port() int { - return os.getenv_opt('GITLY_PORT') or { '8080' }.int() -} +import config enum Lang { en ru } +fn get_port(conf config.Config) int { + // Priority: -p flag > GITLY_PORT env > config.json port > 8080 + for i, arg in os.args { + if (arg == '-p' || arg == '--port') && i + 1 < os.args.len { + return os.args[i + 1].int() + } + } + env_port := os.getenv_opt('GITLY_PORT') or { '' } + if env_port != '' { + return env_port.int() + } + if conf.port > 0 { + return conf.port + } + return 8080 +} + fn main() { if os.args.contains('ci_run') { return @@ -19,13 +31,13 @@ fn main() { mut app := new_app()! app.use(handler: app.before_request) - // vweb.run_at(new_app()!, http_port) + + app.port = get_port(app.config) veb.run_at[App, Context](mut app, - port: http_port + port: app.port family: .ip timeout_in_seconds: 5 - // benchmark_page_generation: true ) or { panic(err) } } diff --git a/src/branch.v b/repo/branch.v similarity index 100% rename from src/branch.v rename to repo/branch.v diff --git a/src/branch_routes.v b/repo/branch_routes.v similarity index 94% rename from src/branch_routes.v rename to repo/branch_routes.v index 41e246d..7f0b6f7 100644 --- a/src/branch_routes.v +++ b/repo/branch_routes.v @@ -30,5 +30,5 @@ pub fn (mut app App) branches(username string, repo_name string) veb.Result { return ctx.json_error('Not found') } branches := app.get_all_repo_branches(repo.id) - return $veb.html() + return $veb.html('../templates/branches.html') } diff --git a/src/branch_template.v b/repo/branch_template.v similarity index 100% rename from src/branch_template.v rename to repo/branch_template.v diff --git a/src/comment.v b/repo/comment.v similarity index 100% rename from src/comment.v rename to repo/comment.v diff --git a/src/file.v b/repo/file.v similarity index 95% rename from src/file.v rename to repo/file.v index 2209e89..af77ec5 100644 --- a/src/file.v +++ b/repo/file.v @@ -40,6 +40,9 @@ fn (f &File) full_path() string { } fn (f File) pretty_last_time() string { + if f.last_time == 0 { + return '' + } return time.unix(f.last_time).relative() } @@ -62,6 +65,13 @@ fn (f File) pretty_size() string { return '${size_in} ${sizes[index]}' } +struct FileInfo { + name string + last_msg string + last_hash string + last_time string +} + fn calculate_lines_of_code(source string) (int, int) { lines := source.split_into_lines() loc := lines.len diff --git a/repo/file_routes.v b/repo/file_routes.v new file mode 100644 index 0000000..b1d2945 --- /dev/null +++ b/repo/file_routes.v @@ -0,0 +1,291 @@ +module main + +import veb +import os + +// GET /:username/:repo_name/new/:branch_name - Show create file form +@['/:username/:repo_name/new/:branch_name'] +pub fn (mut app App) new_file(username string, repo_name string, branch_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + + if !ctx.logged_in || repo.user_id != ctx.user.id { + return ctx.redirect_to_repository(username, repo_name) + } + + default_content := '' + default_filename := '' + return $veb.html('../templates/new_file.html') +} + +// GET /:username/:repo_name/new-ci-file - Show create .gitly-ci.yml form (pre-filled) +@['/:username/:repo_name/new-ci-file'] +pub fn (mut app App) new_ci_file(username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + + if !ctx.logged_in || repo.user_id != ctx.user.id { + return ctx.redirect_to_repository(username, repo_name) + } + + branch_name := repo.primary_branch + default_filename := '.gitly-ci.yml' + default_content := 'jobs: + build: + steps: + - name: Build + run: echo "hello world" + - name: Test + run: echo "running tests" +' + return $veb.html('../templates/new_file.html') +} + +// GET /:username/:repo_name/edit/:branch_name/:path... - Show edit file form +@['/:username/:repo_name/edit/:branch_name/:path...'] +pub fn (mut app App) edit_file(username string, repo_name string, branch_name string, path string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } + + if !ctx.logged_in || repo.user_id != ctx.user.id { + return ctx.redirect_to_repository(username, repo_name) + } + + file_content := repo.read_file(branch_name, path) + + return $veb.html('../templates/edit_file.html') +} + +// POST /:username/:repo_name/update-file - Save edited file +@['/:username/:repo_name/update-file'; post] +pub fn (mut app App) handle_update_file(username string, repo_name string) veb.Result { + mut repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.not_found() + } + + if !ctx.logged_in || repo.user_id != ctx.user.id { + return ctx.redirect_to_repository(username, repo_name) + } + + file_path := ctx.form['file_path'] + file_content := ctx.form['file_content'] + branch_name := ctx.form['branch'] + commit_message := ctx.form['commit_message'] + + if commit_message == '' { + ctx.error('Commit message is required') + path := file_path + return $veb.html('../templates/edit_file.html') + } + + mut actual_branch := branch_name + if actual_branch == '' { + actual_branch = repo.primary_branch + } + + success := app.create_file_in_bare_repo(mut repo, actual_branch, file_path, file_content, + commit_message, ctx.user.username) + + if !success { + ctx.error('Failed to save file') + path := file_path + return $veb.html('../templates/edit_file.html') + } + + // Clear cached files so the updated file shows up + app.delete_repository_files_in_branch(repo.id, actual_branch) or {} + + app.update_repo_after_push(repo.id, actual_branch) or { + app.warn('Failed to update repo after file edit: ${err}') + } + + // Trigger CI if applicable + if file_path == '.gitly-ci.yml' { + spawn app.trigger_ci_with_config(repo.id, actual_branch, file_content) + } else { + spawn app.trigger_ci_if_configured(repo.id, actual_branch) + } + + return ctx.redirect('/${username}/${repo_name}/blob/${actual_branch}/${file_path}') +} + +// POST /:username/:repo_name/create-file - Create a file in the repo +@['/:username/:repo_name/create-file'; post] +pub fn (mut app App) handle_create_file(username string, repo_name string) veb.Result { + mut repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.not_found() + } + + if !ctx.logged_in || repo.user_id != ctx.user.id { + return ctx.redirect_to_repository(username, repo_name) + } + + file_path := ctx.form['file_path'] + file_content := ctx.form['file_content'] + branch_name := ctx.form['branch'] + commit_message := ctx.form['commit_message'] + + if file_path == '' { + ctx.error('File path is required') + default_content := file_content + default_filename := file_path + return $veb.html('../templates/new_file.html') + } + + if commit_message == '' { + ctx.error('Commit message is required') + default_content := file_content + default_filename := file_path + return $veb.html('../templates/new_file.html') + } + + // Sanitize file path + if file_path.contains('..') || file_path.contains('&') || file_path.contains(';') { + ctx.error('Invalid file path') + default_content := file_content + default_filename := file_path + return $veb.html('../templates/new_file.html') + } + + mut actual_branch := branch_name + if actual_branch == '' { + actual_branch = repo.primary_branch + } + + success := app.create_file_in_bare_repo(mut repo, actual_branch, file_path, file_content, + commit_message, ctx.user.username) + + if !success { + ctx.error('Failed to create file') + default_content := file_content + default_filename := file_path + return $veb.html('../templates/new_file.html') + } + + // Clear cached files so the new file shows up + app.delete_repository_files_in_branch(repo.id, actual_branch) or {} + + // Update repo data + app.update_repo_after_push(repo.id, actual_branch) or { + app.warn('Failed to update repo after file creation: ${err}') + } + + // Trigger CI — if we just created .gitly-ci.yml, pass the content directly + if file_path == '.gitly-ci.yml' { + spawn app.trigger_ci_with_config(repo.id, actual_branch, file_content) + } else { + spawn app.trigger_ci_if_configured(repo.id, actual_branch) + } + + return ctx.redirect('/${username}/${repo_name}') +} + +// Creates a file in a bare git repo using plumbing commands +fn (mut app App) create_file_in_bare_repo(mut repo Repo, branch string, file_path string, content string, message string, author string) bool { + git_dir := repo.git_dir + app.info('Creating file ${file_path} in ${git_dir} on branch ${branch}') + + // Write content to a temp file, then hash it into git + tmp_file := '/tmp/gitly_newfile_${repo.id}' + os.write_file(tmp_file, content) or { + app.warn('Failed to write temp file: ${err}') + return false + } + defer { + os.rm(tmp_file) or {} + } + + // 1. Hash the blob + blob_hash := sh('git -C ${git_dir} hash-object -w ${tmp_file}') + if blob_hash == '' { + app.warn('hash-object failed') + return false + } + + // 2. Read the current tree for this branch (if it exists) + mut parent_commit := '' + existing_tree := sh('git -C ${git_dir} rev-parse "${branch}^{tree}"') + has_existing_tree := existing_tree != '' + + // Get parent commit hash + parent_commit = sh('git -C ${git_dir} rev-parse ${branch}') + + // 3. Build a new tree + mut new_tree_hash := '' + if has_existing_tree { + tmp_index := '/tmp/gitly_index_${repo.id}' + defer { + os.rm(tmp_index) or {} + } + + // Read existing tree into temp index + r1 := os.execute('/bin/sh -c \'GIT_INDEX_FILE=${tmp_index} git -C ${git_dir} read-tree ${existing_tree}\'') + if r1.exit_code != 0 { + app.warn('read-tree failed: ${r1.output}') + return false + } + + // Add the new blob to the index + r2 := os.execute('/bin/sh -c \'GIT_INDEX_FILE=${tmp_index} git -C ${git_dir} update-index --add --cacheinfo 100644,${blob_hash},${file_path}\'') + if r2.exit_code != 0 { + app.warn('update-index failed: ${r2.output}') + return false + } + + // Write the tree + r3 := os.execute('/bin/sh -c \'GIT_INDEX_FILE=${tmp_index} git -C ${git_dir} write-tree\'') + if r3.exit_code != 0 { + app.warn('write-tree failed: ${r3.output}') + return false + } + new_tree_hash = r3.output.trim_space() + } else { + // No existing tree — create from scratch using mktree + tree_entry := '100644 blob ${blob_hash}\t${file_path}' + tmp_tree := '/tmp/gitly_tree_${repo.id}' + os.write_file(tmp_tree, tree_entry + '\n') or { return false } + defer { + os.rm(tmp_tree) or {} + } + r := os.execute('/bin/sh -c \'git -C ${git_dir} mktree < ${tmp_tree}\'') + if r.exit_code != 0 { + app.warn('mktree failed: ${r.output}') + return false + } + new_tree_hash = r.output.trim_space() + } + + if new_tree_hash == '' { + app.warn('Failed to create tree') + return false + } + + // 4. Create a commit + mut parent_flag := '' + if parent_commit != '' { + parent_flag = '-p ${parent_commit}' + } + + commit_sh := 'GIT_AUTHOR_NAME="${author}" GIT_AUTHOR_EMAIL="${author}@gitly" GIT_COMMITTER_NAME="${author}" GIT_COMMITTER_EMAIL="${author}@gitly" git -C ${git_dir} commit-tree ${new_tree_hash} ${parent_flag} -m "${message}"' + r4 := os.execute("/bin/sh -c '${commit_sh}'") + if r4.exit_code != 0 { + app.warn('commit-tree failed: ${r4.output}') + return false + } + new_commit_hash := r4.output.trim_space() + + // 5. Update the branch ref + r5 := os.execute('git -C ${git_dir} update-ref refs/heads/${branch} ${new_commit_hash}') + if r5.exit_code != 0 { + app.warn('update-ref failed: ${r5.output}') + return false + } + + app.info('File ${file_path} created with commit ${new_commit_hash}') + return true +} + +fn sh(cmd string) string { + r := os.execute('/bin/sh -c \'${cmd}\'') + if r.exit_code != 0 { + return '' + } + return r.output.trim_space() +} diff --git a/src/file_template.v b/repo/file_template.v similarity index 100% rename from src/file_template.v rename to repo/file_template.v diff --git a/src/git.v b/repo/git.v similarity index 100% rename from src/git.v rename to repo/git.v diff --git a/src/git_routes.v b/repo/git_routes.v similarity index 94% rename from src/git_routes.v rename to repo/git_routes.v index 0a6e81e..06535ae 100644 --- a/src/git_routes.v +++ b/repo/git_routes.v @@ -22,7 +22,7 @@ fn (mut app App) handle_git_info(username string, git_repo_name string) veb.Resu is_private_repo := !repo.is_public if is_receive_service || is_private_repo { - app.check_git_http_access(mut ctx, username, repo_name) or { return ctx.ok('') } + app.check_git_http_access(mut ctx, username, repo_name) or { return veb.no_result() } } refs := repo.git_advertise(service.str()) @@ -43,7 +43,7 @@ fn (mut app App) handle_git_upload_pack(username string, git_repo_name string) v is_private_repo := !repo.is_public if is_private_repo { - app.check_git_http_access(mut ctx, username, repo_name) or { return ctx.ok('') } + app.check_git_http_access(mut ctx, username, repo_name) or { return veb.no_result() } } git_response := repo.git_smart('upload-pack', body) @@ -60,22 +60,21 @@ fn (mut app App) handle_git_receive_pack(username string, git_repo_name string) user := app.get_user_by_username(username) or { return ctx.not_found() } repo := app.find_repo_by_name_and_user_id(repo_name, user.id) or { return ctx.not_found() } - app.check_git_http_access(mut ctx, username, repo_name) or { return ctx.ok('') } + app.check_git_http_access(mut ctx, username, repo_name) or { return veb.no_result() } git_response := repo.git_smart('receive-pack', body) branch_name := git.parse_branch_name_from_receive_upload(body) or { - ctx.send_internal_error('Receive upload parsing error') - - return ctx.ok('') + return ctx.server_error('Receive upload parsing error') } app.update_repo_after_push(repo.id, branch_name) or { - ctx.send_internal_error('There was an error while updating the repo') - - return ctx.ok('') + return ctx.server_error('There was an error while updating the repo') } + // Trigger CI if .gitly-ci.yml exists in the repo + spawn app.trigger_ci_if_configured(repo.id, branch_name) + ctx.set_git_content_type_headers(.receive) return ctx.ok(git_response) @@ -87,6 +86,7 @@ fn (mut app App) check_git_http_access(mut ctx Context, repository_owner string, if !has_valid_auth_header { ctx.set_authenticate_headers() ctx.send_unauthorized() + return none } has_user_valid_credentials := app.check_user_credentials(ctx) diff --git a/src/lang_stats.v b/repo/lang_stats.v similarity index 100% rename from src/lang_stats.v rename to repo/lang_stats.v diff --git a/src/release.v b/repo/release.v similarity index 100% rename from src/release.v rename to repo/release.v diff --git a/src/release_routes.v b/repo/release_routes.v similarity index 96% rename from src/release_routes.v rename to repo/release_routes.v index 28d55af..e91e7a3 100644 --- a/src/release_routes.v +++ b/repo/release_routes.v @@ -53,5 +53,5 @@ pub fn (mut app App) releases(mut ctx Context, username string, repo_name string releases << release } - return $veb.html() + return $veb.html('../templates/releases.html') } diff --git a/src/repo.v b/repo/repo.v similarity index 96% rename from src/repo.v rename to repo/repo.v index a097bd8..901bc87 100644 --- a/src/repo.v +++ b/repo/repo.v @@ -250,6 +250,9 @@ fn (mut app App) delete_repository(id int, path string, name string) ! { app.delete_repo_folder(path) app.info('Removed repo folder (${id}, ${name})') + + app.delete_repo_ci_statuses(id) or {} + app.info('Removed repo CI statuses (${id}, ${name})') } fn (mut app App) move_repo_to_user(repo_id int, user_id int, user_name string) ! { @@ -308,22 +311,25 @@ fn (mut app App) update_repo_branch_from_fs(mut repo Repo, branch_name string) ! if branch.id == 0 { return } - // $dbg; data := repo.git('--no-pager log ${branch_name} --abbrev-commit --abbrev=7 --pretty="%h${log_field_separator}%aE${log_field_separator}%cD${log_field_separator}%s${log_field_separator}%aN"') - // println('DATA=') - // println(data) for line in data.split_into_lines() { args := line.split(log_field_separator) - if args.len > 3 { + if args.len > 4 { commit_hash := args[0] commit_author_email := args[1] commit_message := args[3] commit_author := args[4] mut commit_author_id := 0 + // git log outputs newest commits first; if this commit already + // exists, all subsequent (older) commits do too — stop early. + if app.commit_exists(repo_id, branch.id, commit_hash) { + break + } + commit_date := time.parse_rfc2822(args[2]) or { app.info('Error: ${err}') return @@ -337,9 +343,7 @@ fn (mut app App) update_repo_branch_from_fs(mut repo Repo, branch_name string) ! commit_author_id = user.id } - // $dbg; - - app.add_commit_if_not_exist(repo_id, branch.id, commit_hash, commit_author, + app.add_commit(repo_id, branch.id, commit_hash, commit_author, commit_author_id, commit_message, int(commit_date.unix()))! } } @@ -393,13 +397,17 @@ fn (mut app App) update_repo_branch_data(mut repo Repo, branch_name string) ! { for line in data.split_into_lines() { args := line.split(log_field_separator) - if args.len > 3 { + if args.len > 4 { commit_hash := args[0] commit_author_email := args[1] commit_message := args[3] commit_author := args[4] mut commit_author_id := 0 + if app.commit_exists(repo_id, branch.id, commit_hash) { + break + } + commit_date := time.parse_rfc2822(args[2]) or { app.info('Error: ${err}') return @@ -413,8 +421,7 @@ fn (mut app App) update_repo_branch_data(mut repo Repo, branch_name string) ! { commit_author_id = user.id } - // $dbg; - app.add_commit_if_not_exist(repo_id, branch.id, commit_hash, commit_author, + app.add_commit(repo_id, branch.id, commit_hash, commit_author, commit_author_id, commit_message, int(commit_date.unix()))! } } @@ -584,7 +591,6 @@ fn (r &Repo) parse_ls(ls_line string, branch string) ?File { item_type := ls_line_parts[1] item_size := ls_line_parts[3] item_path := ls_line_parts[4] - item_hash := r.git('log ${branch} -n 1 --format="%h" -- ${item_path}') item_name := item_path.after('/') if item_name == '' { @@ -604,7 +610,6 @@ fn (r &Repo) parse_ls(ls_line string, branch string) ?File { name: item_name parent_path: parent_path repo_id: r.id - last_hash: item_hash branch: branch is_dir: item_type == 'tree' size: if item_type == 'blob' { item_size.int() } else { 0 } @@ -769,11 +774,12 @@ fn (mut app App) fetch_file_info(r &Repo, file &File) ! { return } last_msg := first_line(vals[0]) - last_time := vals[1].int() // last_hash + last_time := vals[1].int() + last_hash := vals[2] file_id := file.id sql app.db { - update File set last_msg = last_msg, last_time = last_time where id == file_id + update File set last_msg = last_msg, last_time = last_time, last_hash = last_hash where id == file_id }! } diff --git a/src/repo_routes.v b/repo/repo_routes.v similarity index 83% rename from src/repo_routes.v rename to repo/repo_routes.v index b919ea2..bb4c0fc 100644 --- a/src/repo_routes.v +++ b/repo/repo_routes.v @@ -4,9 +4,9 @@ import veb import crypto.sha1 import os import highlight -import time import validation import git +import db.pg @['/:username/repos'] pub fn (mut app App) user_repos(username string) veb.Result { @@ -22,7 +22,7 @@ pub fn (mut app App) user_repos(username string) veb.Result { repos = app.find_user_repos(user.id) } - return $veb.html() + return $veb.html('../templates/user/repos.html') } @['/:username/stars'] @@ -35,7 +35,7 @@ pub fn (mut app App) user_stars(username string) veb.Result { repos := app.find_user_starred_repos(ctx.user.id) - return $veb.html() + return $veb.html('../templates/user/stars.html') } @['/:username/:repo_name/settings'] @@ -49,7 +49,7 @@ pub fn (mut app App) repo_settings(username string, repo_name string) veb.Result return ctx.redirect_to_repository(username, repo_name) } - return $veb.html() + return $veb.html('../templates/repo/settings.html') } @['/:username/:repo_name/settings'; post] @@ -181,7 +181,7 @@ pub fn (mut app App) new() veb.Result { if !ctx.logged_in { return ctx.redirect_to_login() } - return $veb.html() + return $veb.html('../templates/new.html') } @['/new'; post] @@ -254,7 +254,7 @@ pub fn (mut app App) handle_new_repo(mut ctx Context, name string, clone_url str // t := time.now() new_repo.status = .cloning - spawn app.clone_repo(mut new_repo) + spawn clone_repo(mut new_repo) // new_repo.clone() // println(time.since(t)) } @@ -296,17 +296,50 @@ pub fn (mut app App) handle_new_repo(mut ctx Context, name string, clone_url str return ctx.redirect('/${ctx.user.username}/${new_repo.name}') } -pub fn (mut app App) clone_repo(mut new_repo Repo) { +fn bg_fetch_files_info(repo_ Repo, branch string, path string) { + mut repo := repo_ + mut app := &App{ + db: pg.connect( + dbname: 'gitly' + user: 'gitly' + password: 'gitly' + ) or { + eprintln('cannot open db connection for bg_fetch thread: ${err}') + return + } + } + app.slow_fetch_files_info(mut repo, branch, path) or { + eprintln('bg_fetch_files_info error: ${err}') + } + app.db.close() or {} +} + +fn clone_repo(mut new_repo Repo) { new_repo.clone() - app.debug('cloning done') - app.update_repo_from_fs(mut new_repo) or { eprintln('cannot update repo from fs ${err}') } - eprintln('setting repo status to done after cloning xxx') + // Use a dedicated DB connection for the clone thread to avoid + // corrupting the main connection's PostgreSQL protocol state. + mut app := &App{ + db: pg.connect( + dbname: 'gitly' + user: 'gitly' + password: 'gitly' + ) or { + eprintln('cannot open db connection for clone thread: ${err}') + return + } + } + // Mark repo as done immediately so the user can browse it. + // The tree page will fetch files from git on demand. app.set_repo_status(new_repo.id, .done) or { eprintln('cannot set repo status ${err}') } - // git.clone(valid_clone_url, repo_path) + eprintln('clone done, repo available — indexing in background') + // Index branches, commits, and language stats in the background. + app.update_repo_from_fs(mut new_repo) or { eprintln('cannot update repo from fs ${err}') } + eprintln('background indexing complete') + app.db.close() or {} } pub fn (mut app App) kekw(mut ctx Context) veb.Result { - return $veb.html('templates/cloning_in_process.html') + return $veb.html('../templates/cloning_in_process.html') } @['/:username/:repo_name/tree/:branch_name/:path...'] @@ -317,7 +350,7 @@ pub fn (mut app App) tree(mut ctx Context, username string, repo_name string, br } eprintln('!!! REPO STATUS = ${repo.status}') if repo.status == .cloning { - return $veb.html('templates/cloning_in_process.html') + return $veb.html('../templates/cloning_in_process.html') } _, user := app.check_username(username) @@ -365,7 +398,6 @@ pub fn (mut app App) tree(mut ctx Context, username string, repo_name string, br branch := app.find_repo_branch_by_name(repo.id, branch_name) app.info('${log_prefix}: ${items.len} items found in branch ${branch_name}') - println(items) if items.len == 0 { // No files in the db, fetch them from git and cache in db @@ -375,17 +407,11 @@ pub fn (mut app App) tree(mut ctx Context, username string, repo_name string, br app.info(err.str()) []File{} } - app.slow_fetch_files_info(mut repo, branch_name, ctx.current_path) or { - app.info(err.str()) - } - } - - if items.any(it.last_msg == '') { - // If any of the files has a missing `last_msg`, we need to refetch it. - println('no last msg') - app.slow_fetch_files_info(mut repo, branch_name, ctx.current_path) or { - app.info(err.str()) - } + // Fetch commit info in background — don't block the page + spawn bg_fetch_files_info(repo, branch_name, ctx.current_path) + } else if items.any(it.last_msg == '') { + // Some files still need commit info — fetch in background + spawn bg_fetch_files_info(repo, branch_name, ctx.current_path) } // Fetch last commit message for this directory, printed at the top of the tree @@ -399,26 +425,12 @@ pub fn (mut app App) tree(mut ctx Context, username string, repo_name string, br p = '/${p}' } if dir := app.find_repo_file_by_path(repo.id, branch_name, p) { - println('hash=${dir.last_hash}') last_commit = app.find_repo_commit_by_hash(repo.id, dir.last_hash) } } else { last_commit = app.find_repo_last_commit(repo.id, branch.id) } - diff := int(time.ticks() - ctx.page_gen_start) - println('DIFF=${diff}') - if diff == 0 { - ctx.page_gen_time = '<1ms' - } else { - ctx.page_gen_time = '${diff}ms' - } - - // Update items after fetching info - items = app.find_repository_items(repo_id, branch_name, ctx.current_path) - println('new items') - println(items) - dirs := items.filter(it.is_dir) files := items.filter(!it.is_dir) @@ -455,7 +467,15 @@ pub fn (mut app App) tree(mut ctx Context, username string, repo_name string, br is_repo_watcher := app.check_repo_watcher_status(repo_id, ctx.user.id) is_top_directory := ctx.current_path == '' - return $veb.html() + // CI status for last commit + ci_status := app.find_ci_status_for_commit(repo_id, last_commit.hash) or { + app.find_ci_status_for_branch(repo_id, branch_name) or { + CiStatus{} + } + } + has_ci := ci_status.id != 0 + + return $veb.html('../templates/tree.html') } @['/api/v1/repos/:repo_id/star'; 'post'] @@ -496,13 +516,44 @@ pub fn (mut app App) handle_api_repo_watch(mut ctx Context, repo_id_str string) return ctx.json_success(is_watching) } +// API: get file listing with commit info for a directory (used by JS polling) +@['/api/v1/repos/:repo_id_str/files'] +pub fn (mut app App) handle_api_repo_files(mut ctx Context, repo_id_str string) veb.Result { + repo_id := repo_id_str.int() + repo := app.find_repo_by_id(repo_id) or { return ctx.json_error('Not found') } + + if !repo.is_public && repo.user_id != ctx.user.id { + return ctx.json_error('Not found') + } + + branch := if 'branch' in ctx.query { ctx.query['branch'] } else { '' } + path := if 'path' in ctx.query { ctx.query['path'] } else { '' } + + if branch == '' { + return ctx.json_error('branch is required') + } + + items := app.find_repository_items(repo_id, branch, path) + mut result := []FileInfo{} + for item in items { + result << FileInfo{ + name: item.name + last_msg: item.last_msg + last_hash: item.last_hash + last_time: item.pretty_last_time() + } + } + + return ctx.json_success(result) +} + @['/:username/:repo_name/contributors'] pub fn (mut app App) contributors(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() } contributors := app.find_repo_registered_contributor(repo.id) - return $veb.html() + return $veb.html('../templates/contributors.html') } @['/:username/:repo_name/blob/:branch_name/:path...'] @@ -529,7 +580,7 @@ pub fn (mut app App) blob(mut ctx Context, username string, repo_name string, br source := veb.RawHtml(highlighted_source) loc, sloc := calculate_lines_of_code(plain_text) - return $veb.html() + return $veb.html('../templates/blob.html') } @['/:user/:repository/raw/:branch_name/:path...'] diff --git a/src/repo_template.v b/repo/repo_template.v similarity index 100% rename from src/repo_template.v rename to repo/repo_template.v diff --git a/src/star.v b/repo/star.v similarity index 100% rename from src/star.v rename to repo/star.v diff --git a/src/tag.v b/repo/tag.v similarity index 100% rename from src/tag.v rename to repo/tag.v diff --git a/src/tag_routes.v b/repo/tag_routes.v similarity index 100% rename from src/tag_routes.v rename to repo/tag_routes.v diff --git a/src/watch.v b/repo/watch.v similarity index 100% rename from src/watch.v rename to repo/watch.v diff --git a/run.sh b/run.sh index e325d33..559c727 100755 --- a/run.sh +++ b/run.sh @@ -1,3 +1,3 @@ -sassc src/static/css/gitly.scss > src/static/css/gitly.css +sassc static/css/gitly.scss > static/css/gitly.css v . ./gitly diff --git a/src/search_routes.v b/search_routes.v similarity index 100% rename from src/search_routes.v rename to search_routes.v diff --git a/src/security_log.v b/security_log.v similarity index 100% rename from src/security_log.v rename to security_log.v diff --git a/src/security_log_routes.v b/security_log_routes.v similarity index 100% rename from src/security_log_routes.v rename to security_log_routes.v diff --git a/src/settings.v b/settings.v similarity index 100% rename from src/settings.v rename to settings.v diff --git a/src/static/js/tree.js b/src/static/js/tree.js deleted file mode 100644 index 1884f9e..0000000 --- a/src/static/js/tree.js +++ /dev/null @@ -1,78 +0,0 @@ -const branchSelectEl = document.querySelector(".branch-select"); -branchSelectEl.addEventListener("change", (event) => { - window.location.href = TREE_BRANCH_PATH_TEMPLATE + event.target.value; -}); - -branchSelectEl.value = BRANCH_NAME; - -// Make the entire row clickable -const fileEls = document.querySelectorAll(".file"); - -fileEls.forEach(fileEl => { - fileEl.addEventListener("click", () => { - window.location = fileEl.querySelector("a").href; - }); -}); - -const starButtonEl = document.querySelector(".star-button"); - -async function starRepo(repoId) { - const url = "/api/v1/repos/" + repoId + "/star"; - const response = await fetch(url, { - method: "POST" - }); - const json = await response.json(); - - if (json.success) { - return json.result === "true"; - } else { - throw new Error(json.message); - } -} - -starButtonEl.addEventListener("click", () => { - starRepo(REPO_ID) - .then(() => { - location.reload() - }) - .catch((error) => { - alert(error.toString()); - }) -}); - -const copyCloneURLButton = document.querySelector(".copy-clone-url-button"); -copyCloneURLButton.addEventListener("click", async () => { - const url = document.querySelector(".clone-input-group > input").value; - - if (navigator && navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(url); - } - - alert("The Clipboard API is not available."); -}); - -const watchButtonEl = document.querySelector(".watch-button"); - -async function watchRepo(repoId) { - const url = "/api/v1/repos/" + repoId + "/watch"; - const response = await fetch(url, { - method: "POST" - }); - const json = await response.json(); - - if (json.success) { - return json.result; - } else { - throw new Error(json.message); - } -} - -watchButtonEl.addEventListener("click", () => { - watchRepo(REPO_ID) - .then(() => { - location.reload() - }) - .catch((error) => { - alert(error.toString()); - }) -}); diff --git a/src/ssh_key.v b/ssh_key.v similarity index 100% rename from src/ssh_key.v rename to ssh_key.v diff --git a/src/ssh_key_routes.v b/ssh_key_routes.v similarity index 100% rename from src/ssh_key_routes.v rename to ssh_key_routes.v diff --git a/src/static/assets/circular_progress_bar.gif b/static/static/assets/circular_progress_bar.gif similarity index 100% rename from src/static/assets/circular_progress_bar.gif rename to static/static/assets/circular_progress_bar.gif diff --git a/src/static/assets/default_avatar.png b/static/static/assets/default_avatar.png similarity index 100% rename from src/static/assets/default_avatar.png rename to static/static/assets/default_avatar.png diff --git a/src/static/assets/favicon.svg b/static/static/assets/favicon.svg similarity index 100% rename from src/static/assets/favicon.svg rename to static/static/assets/favicon.svg diff --git a/static/static/assets/version b/static/static/assets/version new file mode 100644 index 0000000..e69de29 diff --git a/src/static/css/admin.scss b/static/static/css/admin.scss similarity index 100% rename from src/static/css/admin.scss rename to static/static/css/admin.scss diff --git a/src/static/css/blob.scss b/static/static/css/blob.scss similarity index 100% rename from src/static/css/blob.scss rename to static/static/css/blob.scss diff --git a/src/static/css/branches.scss b/static/static/css/branches.scss similarity index 100% rename from src/static/css/branches.scss rename to static/static/css/branches.scss diff --git a/static/static/css/ci.scss b/static/static/css/ci.scss new file mode 100644 index 0000000..f587369 --- /dev/null +++ b/static/static/css/ci.scss @@ -0,0 +1,299 @@ +$ci-success: #28a745; +$ci-failure: #cb2431; +$ci-running: #dbab09; +$ci-pending: #6a737d; +$ci-cancelled: #959da5; +$gray: #dfdfdf; +$gray-light: #f3f3f3; + +.ci-error { + padding: 12px 16px; + margin-bottom: 16px; + background: #ffeef0; + border: 1px solid #fdaeb7; + border-radius: 5px; + color: $ci-failure; + font-size: 14px; +} + +.ci-empty { + padding: 40px 20px; + text-align: center; + color: #6a737d; + + code { + background: $gray-light; + padding: 2px 6px; + border-radius: 3px; + font-size: 13px; + } +} + +// Status badges +.ci-status-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #fff; + text-transform: capitalize; + + &.ci-success { background: $ci-success; } + &.ci-failure { background: $ci-failure; } + &.ci-running { background: $ci-running; color: #24292e; } + &.ci-pending { background: $ci-pending; } + &.ci-cancelled { background: $ci-cancelled; } +} + +// Small inline badge for tree view +.ci-badge-inline { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-left: 6px; + vertical-align: middle; + + &.ci-success { background: $ci-success; } + &.ci-failure { background: $ci-failure; } + &.ci-running { background: $ci-running; } + &.ci-pending { background: $ci-pending; } + &.ci-cancelled { background: $ci-cancelled; } +} + +// CI runs list +.ci-runs { + border: 1px solid $gray; + border-radius: 5px; + overflow: hidden; +} + +.ci-runs-header { + display: flex; + padding: 10px 15px; + background: $gray-light; + border-bottom: 1px solid $gray; + font-weight: 600; + font-size: 13px; + color: #586069; +} + +.ci-run-row { + display: flex; + padding: 10px 15px; + border-bottom: 1px solid $gray; + text-decoration: none; + color: inherit; + align-items: center; + + &:last-child { border-bottom: none; } + &:hover { background: $gray-light; } +} + +.ci-col-status { flex: 0 0 120px; } +.ci-col-branch { flex: 0 0 150px; } +.ci-col-commit { flex: 0 0 120px; } +.ci-col-time { flex: 1; text-align: right; color: #586069; font-size: 13px; } + +// CI run detail page +.ci-run-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; + + h1 { margin: 0; } +} + +// Restart button +.ci-restart-form { + margin-left: auto; +} + +.ci-restart-btn { + padding: 5px 14px; + background: #fff; + color: #24292e; + border: 1px solid $gray; + border-radius: 5px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + + &:hover { + background: $gray-light; + border-color: #c6cbd1; + } +} + +.ci-run-info { + display: flex; + gap: 20px; + padding: 12px 0; + margin-bottom: 20px; + border-bottom: 1px solid $gray; + font-size: 13px; + color: #586069; + + b { color: #24292e; } + code { + background: $gray-light; + padding: 1px 5px; + border-radius: 3px; + font-size: 12px; + } +} + +// CI job block +.ci-job { + border: 1px solid $gray; + border-radius: 5px; + margin-bottom: 16px; + overflow: hidden; +} + +.ci-job-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + background: $gray-light; + border-bottom: 1px solid $gray; + + &.ci-success { border-left: 3px solid $ci-success; } + &.ci-failure { border-left: 3px solid $ci-failure; } + &.ci-running { border-left: 3px solid $ci-running; } + &.ci-pending { border-left: 3px solid $ci-pending; } +} + +// CI steps +.ci-steps { + padding: 0; +} + +.ci-step { + border-bottom: 1px solid $gray; + + &:last-child { border-bottom: none; } +} + +.ci-step-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 15px; + font-size: 13px; + + &.ci-success .ci-step-icon { color: $ci-success; } + &.ci-failure .ci-step-icon { color: $ci-failure; } + &.ci-running .ci-step-icon { color: $ci-running; } + &.ci-pending .ci-step-icon { color: $ci-pending; } +} + +.ci-step-icon { + font-size: 14px; + width: 16px; + text-align: center; +} + +.ci-step-cmd { + background: $gray-light; + padding: 1px 6px; + border-radius: 3px; + font-size: 12px; + color: #586069; + margin-left: auto; +} + +.ci-step-output { + background: #1b1f23; + color: #e1e4e8; + padding: 12px 15px; + margin: 0; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.6; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +// Create CI file button +.ci-create-btn { + display: inline-block; + margin-top: 15px; + padding: 8px 20px; + background: #28a745; + color: #fff !important; + border-radius: 5px; + font-size: 14px; + font-weight: 500; + text-decoration: none; + + &:hover { + background: #22863a; + } +} + +// Create file form +.create-file-form { + margin-top: 15px; +} + +.create-file-path { + display: flex; + align-items: center; + margin-bottom: 10px; + gap: 4px; + + input { + flex: 1; + font-size: 14px; + padding: 6px 10px; + } +} + +.create-file-prefix { + color: #586069; + font-size: 14px; + white-space: nowrap; +} + +.create-file-editor { + width: 100%; + box-sizing: border-box; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + line-height: 1.5; + padding: 12px; + border: 1px solid $gray; + border-radius: 5px; + resize: vertical; + tab-size: 2; +} + +.create-file-commit { + display: flex; + gap: 10px; + margin-top: 10px; + align-items: center; + + input[type="text"] { + flex: 1; + padding: 6px 10px; + } + + input[type="submit"] { + padding: 6px 20px; + background: #28a745; + color: #fff; + border: 1px solid #28a745; + width: auto !important; + + &:hover { + background: #22863a; + border-color: #22863a; + } + } +} diff --git a/src/static/css/commits.scss b/static/static/css/commits.scss similarity index 100% rename from src/static/css/commits.scss rename to static/static/css/commits.scss diff --git a/src/static/css/contributors.scss b/static/static/css/contributors.scss similarity index 100% rename from src/static/css/contributors.scss rename to static/static/css/contributors.scss diff --git a/src/static/css/feed.scss b/static/static/css/feed.scss similarity index 100% rename from src/static/css/feed.scss rename to static/static/css/feed.scss diff --git a/src/static/css/files.scss b/static/static/css/files.scss similarity index 100% rename from src/static/css/files.scss rename to static/static/css/files.scss diff --git a/src/static/css/gitly.scss b/static/static/css/gitly.scss similarity index 100% rename from src/static/css/gitly.scss rename to static/static/css/gitly.scss diff --git a/src/static/css/hl_table.scss b/static/static/css/hl_table.scss similarity index 100% rename from src/static/css/hl_table.scss rename to static/static/css/hl_table.scss diff --git a/src/static/css/issues.scss b/static/static/css/issues.scss similarity index 100% rename from src/static/css/issues.scss rename to static/static/css/issues.scss diff --git a/src/static/css/langs.scss b/static/static/css/langs.scss similarity index 100% rename from src/static/css/langs.scss rename to static/static/css/langs.scss diff --git a/src/static/css/releases.scss b/static/static/css/releases.scss similarity index 100% rename from src/static/css/releases.scss rename to static/static/css/releases.scss diff --git a/src/static/css/search.scss b/static/static/css/search.scss similarity index 100% rename from src/static/css/search.scss rename to static/static/css/search.scss diff --git a/src/static/css/tree.scss b/static/static/css/tree.scss similarity index 92% rename from src/static/css/tree.scss rename to static/static/css/tree.scss index f2322de..1864016 100644 --- a/src/static/css/tree.scss +++ b/static/static/css/tree.scss @@ -69,6 +69,29 @@ gap: 10px; } +.add-file-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: #fafbfc; + border: 1px solid rgba(27, 31, 35, 0.15); + border-radius: 6px; + color: #24292e !important; + font-size: 18px; + font-weight: 400; + line-height: 1; + text-decoration: none; + cursor: pointer; + + &:hover { + background: #2ea44f; + border-color: #2ea44f; + color: #fff !important; + } +} + /* Branch Select Dropdown */ .branch-select { appearance: none; diff --git a/src/static/css/user.scss b/static/static/css/user.scss similarity index 100% rename from src/static/css/user.scss rename to static/static/css/user.scss diff --git a/src/static/js/block-form.js b/static/static/js/block-form.js similarity index 100% rename from src/static/js/block-form.js rename to static/static/js/block-form.js diff --git a/src/static/js/footer.js b/static/static/js/footer.js similarity index 100% rename from src/static/js/footer.js rename to static/static/js/footer.js diff --git a/src/static/js/gitly.js b/static/static/js/gitly.js similarity index 100% rename from src/static/js/gitly.js rename to static/static/js/gitly.js diff --git a/src/static/js/ssh-list.js b/static/static/js/ssh-list.js similarity index 100% rename from src/static/js/ssh-list.js rename to static/static/js/ssh-list.js diff --git a/static/static/js/tree.js b/static/static/js/tree.js new file mode 100644 index 0000000..23f571a --- /dev/null +++ b/static/static/js/tree.js @@ -0,0 +1,143 @@ +const branchSelectEl = document.querySelector(".branch-select"); +branchSelectEl.addEventListener("change", (event) => { + window.location.href = TREE_BRANCH_PATH_TEMPLATE + event.target.value; +}); + +branchSelectEl.value = BRANCH_NAME; + +// Make the entire row clickable +const fileEls = document.querySelectorAll(".file"); + +fileEls.forEach(fileEl => { + fileEl.addEventListener("click", () => { + window.location = fileEl.querySelector("a").href; + }); +}); + +const starButtonEl = document.querySelector(".star-button"); + +async function starRepo(repoId) { + const url = "/api/v1/repos/" + repoId + "/star"; + const response = await fetch(url, { + method: "POST" + }); + const json = await response.json(); + + if (json.success) { + return json.result === "true"; + } else { + throw new Error(json.message); + } +} + +starButtonEl.addEventListener("click", () => { + starRepo(REPO_ID) + .then(() => { + location.reload() + }) + .catch((error) => { + alert(error.toString()); + }) +}); + +const copyCloneURLButton = document.querySelector(".copy-clone-url-button"); +copyCloneURLButton.addEventListener("click", async () => { + const url = document.querySelector(".clone-input-group > input").value; + + if (navigator && navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(url); + } + + alert("The Clipboard API is not available."); +}); + +const watchButtonEl = document.querySelector(".watch-button"); + +async function watchRepo(repoId) { + const url = "/api/v1/repos/" + repoId + "/watch"; + const response = await fetch(url, { + method: "POST" + }); + const json = await response.json(); + + if (json.success) { + return json.result; + } else { + throw new Error(json.message); + } +} + +watchButtonEl.addEventListener("click", () => { + watchRepo(REPO_ID) + .then(() => { + location.reload() + }) + .catch((error) => { + alert(error.toString()); + }) +}); + +// Poll for file commit info (last_msg, last_hash, last_time) that may still be loading +(function() { + // Check if any file rows are missing commit info + function hasMissingInfo() { + const msgEls = document.querySelectorAll("[data-msg-for]"); + for (const el of msgEls) { + const link = el.querySelector("a"); + if (!link || link.textContent.trim() === "") return true; + } + return false; + } + + if (!hasMissingInfo()) return; + + const path = typeof CURRENT_PATH !== "undefined" ? CURRENT_PATH : ""; + const apiUrl = "/api/v1/repos/" + REPO_ID + "/files?branch=" + + encodeURIComponent(BRANCH_NAME) + "&path=" + encodeURIComponent(path); + + let attempts = 0; + const maxAttempts = 30; + + function poll() { + attempts++; + fetch(apiUrl) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success || !data.result) return; + + let stillMissing = false; + for (const file of data.result) { + if (!file.last_msg) { + stillMissing = true; + continue; + } + const msgEl = document.querySelector('[data-msg-for="' + file.name + '"]'); + if (msgEl) { + const link = msgEl.querySelector("a"); + if (link && link.textContent.trim() === "") { + link.textContent = file.last_msg; + if (file.last_hash) { + link.href = "/" + REPO_USER + "/" + REPO_NAME + "/commit/" + file.last_hash; + } + } + } + const timeEl = document.querySelector('[data-time-for="' + file.name + '"]'); + if (timeEl && timeEl.textContent.trim() === "" && file.last_time) { + timeEl.textContent = file.last_time; + } + } + + if (stillMissing && attempts < maxAttempts) { + setTimeout(poll, 2000); + } + }) + .catch(function() { + if (attempts < maxAttempts) { + setTimeout(poll, 2000); + } + }); + } + + // Start polling after a short delay to give the background task time to begin + setTimeout(poll, 1000); +})(); diff --git a/src/static/js/user.js b/static/static/js/user.js similarity index 100% rename from src/static/js/user.js rename to static/static/js/user.js diff --git a/src/static/js/users.js b/static/static/js/users.js similarity index 100% rename from src/static/js/users.js rename to static/static/js/users.js diff --git a/src/static/robots.txt b/static/static/robots.txt similarity index 100% rename from src/static/robots.txt rename to static/static/robots.txt diff --git a/src/templates/admin/settings.html b/templates/admin/settings.html similarity index 100% rename from src/templates/admin/settings.html rename to templates/admin/settings.html diff --git a/src/templates/admin/statistics.html b/templates/admin/statistics.html similarity index 100% rename from src/templates/admin/statistics.html rename to templates/admin/statistics.html diff --git a/src/templates/admin/users.html b/templates/admin/users.html similarity index 100% rename from src/templates/admin/users.html rename to templates/admin/users.html diff --git a/src/templates/blob.html b/templates/blob.html similarity index 84% rename from src/templates/blob.html rename to templates/blob.html index 8977555..05128ea 100644 --- a/src/templates/blob.html +++ b/templates/blob.html @@ -17,6 +17,10 @@ ${file.pretty_size()} | Latest commit hash ${file.last_hash} + @if repo.user_id == ctx.user.id + | + Edit + @end @if is_markdown diff --git a/src/templates/branches.html b/templates/branches.html similarity index 100% rename from src/templates/branches.html rename to templates/branches.html diff --git a/templates/ci_run_detail.html b/templates/ci_run_detail.html new file mode 100644 index 0000000..219c33d --- /dev/null +++ b/templates/ci_run_detail.html @@ -0,0 +1,57 @@ + + + + @include 'layout/head.html' + @css '/css/ci.css' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +
+

CI Run #@{ci_run.id}

+ @{ci_run.status} + @if repo.user_id == ctx.user.id +
+ +
+ @end +
+ +
+ Branch: @{ci_run.branch} + Commit: @{ci_run.commit_hash} + Started: @{ci_run.created_relative()} + Duration: @{ci_run.duration()} +
+ + @for job in ci_run.jobs +
+
+ @job.name + @job.status +
+ +
+ @for step in job.steps +
+
+ @{step.status_icon()} + @step.name + @step.command +
+ @if step.output.len > 0 +
@step.output
+ @end +
+ @end +
+
+ @end +
+ + @include 'layout/footer.html' + + diff --git a/templates/ci_runs.html b/templates/ci_runs.html new file mode 100644 index 0000000..15cd2bd --- /dev/null +++ b/templates/ci_runs.html @@ -0,0 +1,66 @@ + + + + @include 'layout/head.html' + @css '/css/ci.css' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +

CI / CD

+ + @if ci_service_error +
+ CI service unavailable. The CI service is not running or cannot be reached. +
+ @end + + @if ci_runs.len == 0 + @if !ci_service_error +
+ @if has_ci_file +

No CI runs yet. Push a commit to trigger a CI run.

+ @else +

No CI runs yet. Add a .gitly-ci.yml file to your repository to enable CI.

+ @if repo.user_id == ctx.user.id + Create .gitly-ci.yml + @end + @end +
+ @end + @else + + @end +
+ + @include 'layout/footer.html' + + diff --git a/src/templates/cloning_in_process.html b/templates/cloning_in_process.html similarity index 100% rename from src/templates/cloning_in_process.html rename to templates/cloning_in_process.html diff --git a/src/templates/commit.html b/templates/commit.html similarity index 100% rename from src/templates/commit.html rename to templates/commit.html diff --git a/src/templates/commits.html b/templates/commits.html similarity index 100% rename from src/templates/commits.html rename to templates/commits.html diff --git a/src/templates/contributors.html b/templates/contributors.html similarity index 100% rename from src/templates/contributors.html rename to templates/contributors.html diff --git a/templates/edit_file.html b/templates/edit_file.html new file mode 100644 index 0000000..4768777 --- /dev/null +++ b/templates/edit_file.html @@ -0,0 +1,38 @@ + + + + @include 'layout/head.html' + @css '/css/ci.css' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +

Edit @path

+ +
+ @ctx.form_error +
+ +
+ + + +
+ @repo.user_name/@repo.name / @path +
+ + + +
+ + +
+
+
+ + @include 'layout/footer.html' + + diff --git a/src/templates/index.html b/templates/index.html similarity index 100% rename from src/templates/index.html rename to templates/index.html diff --git a/src/templates/issue.html b/templates/issue.html similarity index 100% rename from src/templates/issue.html rename to templates/issue.html diff --git a/src/templates/issues.html b/templates/issues.html similarity index 100% rename from src/templates/issues.html rename to templates/issues.html diff --git a/src/templates/layout/footer.html b/templates/layout/footer.html similarity index 100% rename from src/templates/layout/footer.html rename to templates/layout/footer.html diff --git a/src/templates/layout/head.html b/templates/layout/head.html similarity index 100% rename from src/templates/layout/head.html rename to templates/layout/head.html diff --git a/src/templates/layout/header.html b/templates/layout/header.html similarity index 100% rename from src/templates/layout/header.html rename to templates/layout/header.html diff --git a/src/templates/layout/repo_menu.html b/templates/layout/repo_menu.html similarity index 88% rename from src/templates/layout/repo_menu.html rename to templates/layout/repo_menu.html index c060b54..d08838e 100644 --- a/src/templates/layout/repo_menu.html +++ b/templates/layout/repo_menu.html @@ -33,6 +33,10 @@ @repo.format_nr_tags() + + + CI + @if repo.user_id == ctx.user.id Settings diff --git a/src/templates/layout/tree_path.html b/templates/layout/tree_path.html similarity index 100% rename from src/templates/layout/tree_path.html rename to templates/layout/tree_path.html diff --git a/src/templates/login.html b/templates/login.html similarity index 100% rename from src/templates/login.html rename to templates/login.html diff --git a/src/templates/new.html b/templates/new.html similarity index 100% rename from src/templates/new.html rename to templates/new.html diff --git a/src/templates/new/issue.html b/templates/new/issue.html similarity index 100% rename from src/templates/new/issue.html rename to templates/new/issue.html diff --git a/templates/new_file.html b/templates/new_file.html new file mode 100644 index 0000000..5a6fa37 --- /dev/null +++ b/templates/new_file.html @@ -0,0 +1,38 @@ + + + + @include 'layout/head.html' + @css '/css/ci.css' + + + @include 'layout/header.html' + +
+ @include 'layout/repo_menu.html' + +

Create new file

+ +
+ @ctx.form_error +
+ +
+ + +
+ @repo.user_name/@repo.name / + +
+ + + +
+ + +
+
+
+ + @include 'layout/footer.html' + + diff --git a/src/templates/register.html b/templates/register.html similarity index 100% rename from src/templates/register.html rename to templates/register.html diff --git a/src/templates/releases.html b/templates/releases.html similarity index 100% rename from src/templates/releases.html rename to templates/releases.html diff --git a/src/templates/repo/settings.html b/templates/repo/settings.html similarity index 100% rename from src/templates/repo/settings.html rename to templates/repo/settings.html diff --git a/src/templates/search.html b/templates/search.html similarity index 100% rename from src/templates/search.html rename to templates/search.html diff --git a/src/templates/security.html b/templates/security.html similarity index 100% rename from src/templates/security.html rename to templates/security.html diff --git a/src/templates/svgs/star.html b/templates/svgs/star.html similarity index 100% rename from src/templates/svgs/star.html rename to templates/svgs/star.html diff --git a/src/templates/svgs/unstar.html b/templates/svgs/unstar.html similarity index 100% rename from src/templates/svgs/unstar.html rename to templates/svgs/unstar.html diff --git a/src/templates/svgs/unwatch.html b/templates/svgs/unwatch.html similarity index 100% rename from src/templates/svgs/unwatch.html rename to templates/svgs/unwatch.html diff --git a/src/templates/svgs/watch.html b/templates/svgs/watch.html similarity index 100% rename from src/templates/svgs/watch.html rename to templates/svgs/watch.html diff --git a/src/templates/tree.html b/templates/tree.html similarity index 89% rename from src/templates/tree.html rename to templates/tree.html index 84c0c96..7fa2a94 100644 --- a/src/templates/tree.html +++ b/templates/tree.html @@ -2,6 +2,7 @@ @include 'layout/head.html' + @css '/css/ci.css' @include 'layout/header.html' @@ -10,6 +11,9 @@ const TREE_BRANCH_PATH_TEMPLATE = "/@repo.user_name/@repo.name/tree/"; const BRANCH_NAME = "@branch_name"; const REPO_ID = @repo.id; + const CURRENT_PATH = "@ctx.current_path"; + const REPO_USER = "@repo.user_name"; + const REPO_NAME = "@repo.name";
@@ -41,6 +45,10 @@
+ + @if repo.user_id == ctx.user.id +
+ + @end @if is_top_directory @@ -117,6 +125,11 @@ @last_commit.message } @last_commit.hash + @if has_ci + + + + @end span.time { @last_commit.relative() } @@ -133,7 +146,7 @@ } @end @for file in items - .file { +
span.file-ico { @if file.is_dir @@ -144,13 +157,13 @@ span.file-name { @file.name } - span.file-msg { + @file.format_commit_message() - } - span.file-time { + + @file.pretty_last_time() - } - } + +
@end } diff --git a/src/templates/user.html b/templates/user.html similarity index 100% rename from src/templates/user.html rename to templates/user.html diff --git a/src/templates/user/feed.html b/templates/user/feed.html similarity index 100% rename from src/templates/user/feed.html rename to templates/user/feed.html diff --git a/src/templates/user/issues.html b/templates/user/issues.html similarity index 100% rename from src/templates/user/issues.html rename to templates/user/issues.html diff --git a/src/templates/user/repos.html b/templates/user/repos.html similarity index 100% rename from src/templates/user/repos.html rename to templates/user/repos.html diff --git a/src/templates/user/settings.html b/templates/user/settings.html similarity index 100% rename from src/templates/user/settings.html rename to templates/user/settings.html diff --git a/src/templates/user/ssh/keys/list.html b/templates/user/ssh/keys/list.html similarity index 100% rename from src/templates/user/ssh/keys/list.html rename to templates/user/ssh/keys/list.html diff --git a/src/templates/user/ssh/keys/new.html b/templates/user/ssh/keys/new.html similarity index 100% rename from src/templates/user/ssh/keys/new.html rename to templates/user/ssh/keys/new.html diff --git a/src/templates/user/stars.html b/templates/user/stars.html similarity index 100% rename from src/templates/user/stars.html rename to templates/user/stars.html diff --git a/src/token.v b/token.v similarity index 100% rename from src/token.v rename to token.v diff --git a/src/user.v b/user.v similarity index 98% rename from src/user.v rename to user.v index eb7dec4..8b2a851 100644 --- a/src/user.v +++ b/user.v @@ -309,10 +309,10 @@ pub fn (mut app App) get_count_repo_contributors(id int) !int { } pub fn (mut app App) contains_contributor(user_id int, repo_id int) bool { - contributors := sql app.db { - select from Contributor where repo_id == repo_id && user_id == user_id - } or { [] } - return contributors.len > 0 + count := sql app.db { + select count from Contributor where repo_id == repo_id && user_id == user_id + } or { 0 } + return count > 0 } pub fn (mut app App) increment_user_post(mut user User) ! { diff --git a/src/user_routes.v b/user_routes.v similarity index 100% rename from src/user_routes.v rename to user_routes.v diff --git a/src/utils.v b/utils.v similarity index 100% rename from src/utils.v rename to utils.v diff --git a/v.mod b/v.mod index 7eec3de..7ab0e4a 100644 --- a/v.mod +++ b/v.mod @@ -3,5 +3,6 @@ Module { description: 'A self-hosted Git service, written in V' version: '0.1.1' license: 'GPL 3' + subdirs: ['admin', 'repo', 'commit', 'ci'] dependencies: [] } -- 2.39.5