From 9d8beeb8b8e6ea32e5695e1904210470824df5b2 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 14:49:43 +0300 Subject: [PATCH] api v1: REST endpoints for users, repos, issues, pulls --- api/endpoints_test.v | 605 ++++++++++++++++++++++++++++++++ api_v1_routes.v | 801 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1406 insertions(+) create mode 100644 api/endpoints_test.v create mode 100644 api_v1_routes.v diff --git a/api/endpoints_test.v b/api/endpoints_test.v new file mode 100644 index 0000000..ec3a44c --- /dev/null +++ b/api/endpoints_test.v @@ -0,0 +1,605 @@ +// Integration tests for every /api/v1/ endpoint exposed by gitly. +// +// The suite spawns its own gitly process on a non-default port using a +// dedicated sqlite database, so it can be executed independently of any +// long-running dev instance (run with `v test api/` or `v test .`). +// +// Endpoints covered: +// GET /api/v1/me +// GET /api/v1/users/:username +// GET /api/v1/users/:username/repos +// GET /api/v1/repos/:username/:repo_name +// GET /api/v1/repos/:username/:repo_name/issues +// POST /api/v1/repos/:username/:repo_name/issues +// GET /api/v1/repos/:username/:repo_name/issues/:id +// GET /api/v1/repos/:username/:repo_name/pulls +// GET /api/v1/repos/:username/:repo_name/pulls/:id +// GET /api/v1/repos/:username/:repo_name/pulls/:id/comments +// POST /api/v1/repos/:repo_id/star +// POST /api/v1/repos/:repo_id/watch +// GET /api/v1/repos/:repo_id_str/tree/files +// GET /api/v1/:user/:repo_name/branches/count +// GET /api/v1/:user/:repo_name/:branch_name/commits/count +// GET /api/v1/:username/:repo_name/issues/count +// POST /api/v1/users/avatar +// POST /api/v1/ci/status +module api + +import os +import log +import net.http +import time +import json + +const test_port = 8765 +const test_url = 'http://127.0.0.1:${test_port}' +const test_username = 'apitester' +const test_password = '1234zxcv' +const test_email = 'apitester@example.com' +const test_repo = 'apitest' +const test_other_user = 'apitester2' +const test_other_password = '5678qwer' +const test_other_email = 'apitester2@example.com' + +const test_binary = 'gitly_apitest.exe' +const test_sqlite_path = 'gitly_apitest.sqlite' + +// Test-wide state is passed between testsuite_begin and individual tests via +// environment variables, since `v test` does not allow `__global` declarations +// in module test files. +const env_session = 'GITLY_APITEST_SESSION' +const env_other_session = 'GITLY_APITEST_OTHER_SESSION' +const env_bearer = 'GITLY_APITEST_BEARER' +const env_repo_id = 'GITLY_APITEST_REPO_ID' + +fn session_cookie() string { + return os.getenv(env_session) +} + +fn other_session_cookie() string { + return os.getenv(env_other_session) +} + +fn bearer_token() string { + return os.getenv(env_bearer) +} + +fn repo_id() int { + return os.getenv(env_repo_id).int() +} + +// -- testsuite plumbing ------------------------------------------------------- + +fn testsuite_begin() { + chdir_to_project_root() + cleanup_test_state() + ensure_gitly_binary() + spawn_test_gitly() + wait_for_test_gitly() + + session := register(test_username, test_password, test_email) or { + fail('register primary user: ${err}') + } + os.setenv(env_session, session, true) + + other := register(test_other_user, test_other_password, test_other_email) or { + fail('register secondary user: ${err}') + } + os.setenv(env_other_session, other, true) + + token := create_api_token(session, test_username) or { fail('create api token: ${err}') } + os.setenv(env_bearer, token, true) + + create_repo(session, test_repo) or { fail('create repo: ${err}') } + + rid := fetch_test_repo_id() or { fail('fetch repo id: ${err}') } + os.setenv(env_repo_id, rid.str(), true) +} + +fn testsuite_end() { + kill_test_gitly() + cleanup_test_state() +} + +@[noreturn] +fn fail(msg string) { + log.error('api endpoints_test: ${msg}') + kill_test_gitly() + cleanup_test_state() + exit(1) +} + +fn chdir_to_project_root() { + project_root := os.real_path(os.join_path(os.dir(@FILE), '..')) + os.chdir(project_root) or { fail('chdir to project root ${project_root}: ${err}') } +} + +fn cleanup_test_state() { + for ext in ['', '-shm', '-wal'] { + path := test_sqlite_path + ext + if os.exists(path) { + os.rm(path) or {} + } + } + for user in [test_username, test_other_user] { + repo_path := os.join_path('repos', user) + if os.exists(repo_path) { + os.rmdir_all(repo_path) or {} + } + } +} + +fn ensure_gitly_binary() { + if os.exists(test_binary) { + return + } + log.info('building ${test_binary} ...') + res := os.execute('v -d use_libbacktrace -d use_openssl -o ${test_binary} .') + if res.exit_code != 0 { + fail('failed to build gitly: ${res.output}') + } +} + +fn spawn_test_gitly() { + os.setenv('GITLY_PORT', test_port.str(), true) + os.setenv('GITLY_SQLITE_PATH', test_sqlite_path, true) + spawn fn () { + os.execute('./${test_binary}') + }() +} + +fn wait_for_test_gitly() { + for i := 0; i < 100; i++ { + time.sleep(100 * time.millisecond) + http.get(test_url + '/') or { continue } + return + } + fail('gitly did not start listening on ${test_url}') +} + +fn kill_test_gitly() { + os.execute('pkill -9 ${test_binary}') +} + +// -- helpers ------------------------------------------------------------------ + +fn url(path string) string { + if path.starts_with('/') { + return '${test_url}${path}' + } + return '${test_url}/${path}' +} + +fn extract_token_cookie(h http.Header) string { + for v in h.values(.set_cookie) { + t := v.find_between('token=', ';') + if t != '' { + return t + } + } + return '' +} + +fn register(username string, password string, email string) !string { + body := 'username=${username}&password=${password}&email=${email}&no_redirect=1' + resp := http.post(url('/register'), body)! + if resp.status_code != 200 { + return error('register returned ${resp.status_code}: ${resp.body}') + } + tok := extract_token_cookie(resp.header) + if tok == '' { + return error('no session token cookie in register response') + } + return tok +} + +fn create_repo(token string, name string) ! { + resp := http.fetch( + method: .post + url: url('/new') + cookies: { + 'token': token + } + data: 'name=${name}&description=api+test&clone_url=&repo_visibility=public&no_redirect=1' + )! + if resp.status_code != 200 || resp.body != 'ok' { + return error('unexpected response ${resp.status_code}: ${resp.body}') + } +} + +fn create_api_token(token string, username string) !string { + resp := http.fetch( + method: .post + url: url('/${username}/settings/api-tokens') + cookies: { + 'token': token + } + data: 'name=api-test' + allow_redirect: false + )! + if resp.status_code != 302 && resp.status_code != 303 { + return error('expected redirect, got ${resp.status_code}: ${resp.body}') + } + location := resp.header.get(.location) or { return error('no Location header') } + plain := location.all_after('new_token=') + if plain == '' || plain == location { + return error('could not parse new_token from ${location}') + } + return plain +} + +fn fetch_test_repo_id() !int { + resp := http.get(url('/api/v1/users/${test_username}/repos'))! + if resp.status_code != 200 { + return error('listing returned ${resp.status_code}') + } + repos := json.decode([]ApiRepoSummary, resp.body)! + for r in repos { + if r.name == test_repo { + return r.id + } + } + return error('repo not found in listing') +} + +struct ApiRepoSummary { + id int + name string + user_name string +} + +struct ApiUserSummary { + id int + username string + full_name string + avatar string +} + +struct ApiIssueSummary { + id int + number int + repo_id int + title string + body string + author string + status string +} + +struct ApiPullSummary { + id int + repo_id int + title string + description string + status string +} + +struct ApiCommentSummary { + id int + author string + text string +} + +struct ApiBoolResult { + success bool + result bool +} + +struct ApiFilesResult { + success bool + result []FileSummary +} + +struct FileSummary { + name string + last_msg string + last_hash string + last_time string + size string +} + +fn bearer_header() http.Header { + return http.new_header(key: .authorization, value: 'Bearer ${bearer_token()}') +} + +// -- tests -------------------------------------------------------------------- + +fn test_api_v1_me_requires_auth() { + resp := http.get(url('/api/v1/me')) or { panic(err) } + assert resp.status_code == 401 +} + +fn test_api_v1_me_with_bearer() { + resp := http.fetch( + method: .get + url: url('/api/v1/me') + header: bearer_header() + ) or { panic(err) } + assert resp.status_code == 200 + user := json.decode(ApiUserSummary, resp.body) or { panic(err) } + assert user.username == test_username +} + +fn test_api_v1_me_with_session_cookie() { + resp := http.fetch( + method: .get + url: url('/api/v1/me') + cookies: { + 'token': session_cookie() + } + ) or { panic(err) } + assert resp.status_code == 200 + user := json.decode(ApiUserSummary, resp.body) or { panic(err) } + assert user.username == test_username +} + +fn test_api_v1_user_lookup() { + resp := http.get(url('/api/v1/users/${test_username}')) or { panic(err) } + assert resp.status_code == 200 + user := json.decode(ApiUserSummary, resp.body) or { panic(err) } + assert user.username == test_username + + missing := http.get(url('/api/v1/users/ghost_user')) or { panic(err) } + assert missing.status_code == 404 +} + +fn test_api_v1_user_repos() { + resp := http.get(url('/api/v1/users/${test_username}/repos')) or { panic(err) } + assert resp.status_code == 200 + repos := json.decode([]ApiRepoSummary, resp.body) or { panic(err) } + assert repos.len >= 1 + mut found := false + for r in repos { + if r.name == test_repo { + found = true + break + } + } + assert found +} + +fn test_api_v1_repo_show() { + resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}')) or { panic(err) } + assert resp.status_code == 200 + r := json.decode(ApiRepoSummary, resp.body) or { panic(err) } + assert r.name == test_repo + assert r.user_name == test_username + + missing := http.get(url('/api/v1/repos/${test_username}/nope')) or { panic(err) } + assert missing.status_code == 404 +} + +fn test_api_v1_repo_issues_list_empty() { + resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/issues')) or { panic(err) } + assert resp.status_code == 200 + issues := json.decode([]ApiIssueSummary, resp.body) or { panic(err) } + assert issues.len == 0 +} + +fn test_api_v1_create_issue_requires_auth() { + resp := http.post_form(url('/api/v1/repos/${test_username}/${test_repo}/issues'), { + 'title': 'should-fail' + 'body': 'no token' + }) or { panic(err) } + assert resp.status_code == 401 +} + +fn test_api_v1_create_issue_requires_title() { + resp := http.fetch( + method: .post + url: url('/api/v1/repos/${test_username}/${test_repo}/issues') + header: http.new_header_from_map({ + .authorization: 'Bearer ${bearer_token()}' + .content_type: 'application/x-www-form-urlencoded' + }) + data: 'body=missing-title' + ) or { panic(err) } + assert resp.status_code == 400 +} + +fn test_api_v1_create_issue_succeeds() { + resp := http.fetch( + method: .post + url: url('/api/v1/repos/${test_username}/${test_repo}/issues') + header: http.new_header_from_map({ + .authorization: 'Bearer ${bearer_token()}' + .content_type: 'application/x-www-form-urlencoded' + }) + data: 'title=first-issue&body=hello' + ) or { panic(err) } + assert resp.status_code == 200 + issue := json.decode(ApiIssueSummary, resp.body) or { panic(err) } + assert issue.title == 'first-issue' + assert issue.status == 'open' + + listing := http.get(url('/api/v1/repos/${test_username}/${test_repo}/issues')) or { panic(err) } + issues := json.decode([]ApiIssueSummary, listing.body) or { panic(err) } + assert issues.len >= 1 + + single := http.get(url('/api/v1/repos/${test_username}/${test_repo}/issues/${issue.id}')) or { + panic(err) + } + assert single.status_code == 200 + got := json.decode(ApiIssueSummary, single.body) or { panic(err) } + assert got.id == issue.id +} + +fn test_api_v1_repo_issue_not_found() { + resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/issues/99999')) or { + panic(err) + } + assert resp.status_code == 404 +} + +fn test_api_v1_repo_pulls_empty() { + resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/pulls')) or { panic(err) } + assert resp.status_code == 200 + prs := json.decode([]ApiPullSummary, resp.body) or { panic(err) } + assert prs.len == 0 +} + +fn test_api_v1_repo_pull_not_found() { + resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/pulls/1')) or { panic(err) } + assert resp.status_code == 404 +} + +fn test_api_v1_pull_comments_not_found() { + resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/pulls/1/comments')) or { + panic(err) + } + assert resp.status_code == 404 +} + +fn test_api_v1_issues_count() { + resp := http.fetch( + method: .get + url: url('/api/v1/${test_username}/${test_repo}/issues/count') + cookies: { + 'token': session_cookie() + } + ) or { panic(err) } + assert resp.status_code == 200 + decoded := json.decode(ApiIssueCount, resp.body) or { panic(err) } + assert decoded.success + assert decoded.result >= 1 +} + +fn test_api_v1_issues_count_unauthenticated() { + resp := http.get(url('/api/v1/${test_username}/${test_repo}/issues/count')) or { panic(err) } + assert resp.body.contains('Not found') +} + +fn test_api_v1_branches_count() { + resp := http.fetch( + method: .get + url: url('/api/v1/${test_username}/${test_repo}/branches/count') + cookies: { + 'token': session_cookie() + } + ) or { panic(err) } + assert resp.status_code == 200 + decoded := json.decode(ApiBranchCount, resp.body) or { panic(err) } + assert decoded.success + assert decoded.result == 0 +} + +fn test_api_v1_commits_count() { + resp := http.fetch( + method: .get + url: url('/api/v1/${test_username}/${test_repo}/main/commits/count') + cookies: { + 'token': session_cookie() + } + ) or { panic(err) } + assert resp.status_code == 200 + decoded := json.decode(ApiCommitCount, resp.body) or { panic(err) } + assert decoded.success + assert decoded.result == 0 +} + +fn test_api_v1_repo_star_toggle() { + rid := repo_id() + resp := http.fetch( + method: .post + url: url('/api/v1/repos/${rid}/star') + cookies: { + 'token': session_cookie() + } + ) or { panic(err) } + assert resp.status_code == 200 + first := json.decode(ApiBoolResult, resp.body) or { panic(err) } + assert first.success + assert first.result == true + + resp2 := http.fetch( + method: .post + url: url('/api/v1/repos/${rid}/star') + cookies: { + 'token': session_cookie() + } + ) or { panic(err) } + second := json.decode(ApiBoolResult, resp2.body) or { panic(err) } + assert second.result == false +} + +fn test_api_v1_repo_watch_toggle() { + rid := repo_id() + resp := http.fetch( + method: .post + url: url('/api/v1/repos/${rid}/watch') + cookies: { + 'token': session_cookie() + } + ) or { panic(err) } + assert resp.status_code == 200 + first := json.decode(ApiBoolResult, resp.body) or { panic(err) } + assert first.success +} + +fn test_api_v1_repo_tree_files_requires_branch() { + rid := repo_id() + resp := http.get(url('/api/v1/repos/${rid}/tree/files')) or { panic(err) } + assert resp.body.contains('branch is required') +} + +fn test_api_v1_repo_tree_files_with_branch() { + rid := repo_id() + resp := http.get(url('/api/v1/repos/${rid}/tree/files?branch=main')) or { panic(err) } + assert resp.status_code == 200 + decoded := json.decode(ApiFilesResult, resp.body) or { panic(err) } + assert decoded.success +} + +fn test_api_v1_repo_tree_files_unknown_repo() { + resp := http.get(url('/api/v1/repos/9999999/tree/files?branch=main')) or { panic(err) } + assert resp.body.contains('Not found') +} + +fn test_api_v1_users_avatar_requires_auth() { + resp := http.post_multipart_form(url('/api/v1/users/avatar'), + files: { + 'file': [ + http.FileData{ + filename: 'a.png' + content_type: 'image/png' + data: 'x' + }, + ] + } + ) or { panic(err) } + assert resp.status_code == 404 +} + +fn test_api_v1_ci_status_callback() { + rid := repo_id() + payload := '{"run_id":"123","repo_id":"${rid}","commit_hash":"deadbeef","branch":"main","status":"running"}' + resp := http.fetch( + method: .post + url: url('/api/v1/ci/status') + header: http.new_header(key: .content_type, value: 'application/json') + data: payload + ) or { panic(err) } + assert resp.status_code == 200 + assert resp.body.contains('"success":true') || resp.body.contains('"success": true') +} + +fn test_api_v1_ci_status_callback_rejects_bad_json() { + resp := http.fetch( + method: .post + url: url('/api/v1/ci/status') + header: http.new_header(key: .content_type, value: 'application/json') + data: 'not-json' + ) or { panic(err) } + assert resp.body.contains('Invalid request body') +} + +fn test_api_v1_private_repo_visibility_from_other_user() { + // Sanity check: a second authenticated user can see the public test repo. + resp := http.fetch( + method: .get + url: url('/api/v1/repos/${test_username}/${test_repo}') + cookies: { + 'token': other_session_cookie() + } + ) or { panic(err) } + assert resp.status_code == 200 +} diff --git a/api_v1_routes.v b/api_v1_routes.v new file mode 100644 index 0000000..84251a8 --- /dev/null +++ b/api_v1_routes.v @@ -0,0 +1,801 @@ +// 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 + +struct ApiRepoView { + id int + name string + full_name string + user_name string + description string + is_public bool + stars int + open_issues int + open_prs int + branches int + created_at int +} + +struct ApiIssueView { + id int + number int + repo_id int + title string + body string + author string + status string + comments_count int + created_at int +} + +struct ApiPullView { + id int + repo_id int + title string + description string + head_branch string + base_branch string + author string + status string + comments_count int + created_at int + merged_at int +} + +struct ApiUserView { + id int + username string + full_name string + avatar string +} + +struct ApiCommentView { + id int + author string + text string + created_at int +} + +fn (mut app App) repo_to_api(repo Repo) ApiRepoView { + return ApiRepoView{ + id: repo.id + name: repo.name + full_name: '${repo.user_name}/${repo.name}' + user_name: repo.user_name + description: repo.description + is_public: repo.is_public + stars: repo.nr_stars + open_issues: repo.nr_open_issues + open_prs: repo.nr_open_prs + branches: repo.nr_branches + created_at: repo.created_at + } +} + +fn (mut app App) issue_to_api(issue Issue) ApiIssueView { + author := app.get_username_by_id(issue.author_id) or { '' } + status := if issue.status == .closed { 'closed' } else { 'open' } + return ApiIssueView{ + id: issue.id + number: issue.id + repo_id: issue.repo_id + title: issue.title + body: issue.text + author: author + status: status + comments_count: issue.comments_count + created_at: issue.created_at + } +} + +fn (mut app App) pr_to_api(pr PullRequest) ApiPullView { + author := app.get_username_by_id(pr.author_id) or { '' } + status := match unsafe { PrStatus(pr.status) } { + .open { 'open' } + .closed { 'closed' } + .merged { 'merged' } + } + + return ApiPullView{ + id: pr.id + repo_id: pr.repo_id + title: pr.title + description: pr.description + head_branch: pr.head_branch + base_branch: pr.base_branch + author: author + status: status + comments_count: pr.comments_count + created_at: pr.created_at + merged_at: pr.merged_at + } +} + +fn (ctx &Context) api_bearer_token() string { + header := ctx.get_header(.authorization) or { return '' } + parts := header.fields() + if parts.len != 2 || parts[0] != 'Bearer' { + return '' + } + return parts[1] +} + +fn (mut app App) api_user_from_ctx(ctx &Context) ?User { + token := ctx.api_bearer_token() + if token == '' { + if ctx.logged_in { + return ctx.user + } + return none + } + return app.user_for_api_token(token) +} + +fn (mut ctx Context) api_unauthorized() veb.Result { + ctx.send_custom_error(401, 'Unauthorized') + return ctx.json({ + 'success': 'false' + 'message': 'authentication required' + }) +} + +fn (mut ctx Context) api_not_found() veb.Result { + ctx.send_custom_error(404, 'Not Found') + return ctx.json({ + 'success': 'false' + 'message': 'not found' + }) +} + +@['/api/v1/me'] +pub fn (mut app App) api_v1_me(mut ctx Context) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + return ctx.json(ApiUserView{ + id: user.id + username: user.username + full_name: user.full_name + avatar: user.avatar + }) +} + +@['/api/v1/users/:username'] +pub fn (mut app App) api_v1_user(mut ctx Context, username string) veb.Result { + user := app.get_user_by_username(username) or { return ctx.api_not_found() } + return ctx.json(ApiUserView{ + id: user.id + username: user.username + full_name: user.full_name + avatar: user.avatar + }) +} + +@['/api/v1/users/:username/repos'] +pub fn (mut app App) api_v1_user_repos(mut ctx Context, username string) veb.Result { + user := app.get_user_by_username(username) or { return ctx.api_not_found() } + repos := app.find_user_public_repos(user.id) + mut out := []ApiRepoView{cap: repos.len} + for r in repos { + out << app.repo_to_api(r) + } + return ctx.json(out) +} + +@['/api/v1/repos/:username/:repo_name'] +pub fn (mut app App) api_v1_repo(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + return ctx.json(app.repo_to_api(repo)) +} + +@['/api/v1/repos/:username/:repo_name/issues'] +pub fn (mut app App) api_v1_repo_issues(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + issues := app.find_repo_issues_as_page(repo.id, 0) + mut out := []ApiIssueView{cap: issues.len} + for i in issues { + out << app.issue_to_api(i) + } + return ctx.json(out) +} + +@['/api/v1/repos/:username/:repo_name/issues/:id'] +pub fn (mut app App) api_v1_repo_issue(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.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + issue := app.find_issue_by_id(id.int()) or { return ctx.api_not_found() } + if issue.repo_id != repo.id { + return ctx.api_not_found() + } + return ctx.json(app.issue_to_api(issue)) +} + +@['/api/v1/repos/:username/:repo_name/issues'; post] +pub fn (mut app App) api_v1_create_issue(mut ctx Context, username string, repo_name string) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + if !app.has_user_repo_read_access(ctx, user.id, repo.id) && !repo.is_public { + return ctx.api_not_found() + } + title := ctx.form['title'] + body := ctx.form['body'] + if title == '' { + ctx.send_custom_error(400, 'Bad Request') + return ctx.json({ + 'success': 'false' + 'message': 'title is required' + }) + } + new_id := app.add_issue_returning_id(repo.id, user.id, title, body) or { + ctx.send_custom_error(500, 'Internal Server Error') + return ctx.json({ + 'success': 'false' + 'message': 'failed to create issue' + }) + } + issue := app.find_issue_by_id(new_id) or { return ctx.api_not_found() } + return ctx.json(app.issue_to_api(issue)) +} + +@['/api/v1/repos/:username/:repo_name/pulls'] +pub fn (mut app App) api_v1_repo_pulls(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + prs := app.find_repo_pull_requests(repo.id, .open) + mut out := []ApiPullView{cap: prs.len} + for pr in prs { + out << app.pr_to_api(pr) + } + return ctx.json(out) +} + +@['/api/v1/repos/:username/:repo_name/pulls/:id'] +pub fn (mut app App) api_v1_repo_pull(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.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + pr := app.find_pull_request_by_id(id.int()) or { return ctx.api_not_found() } + if pr.repo_id != repo.id { + return ctx.api_not_found() + } + return ctx.json(app.pr_to_api(pr)) +} + +@['/api/v1/repos/:username/:repo_name/pulls/:id/comments'] +pub fn (mut app App) api_v1_pull_comments(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.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + pr := app.find_pull_request_by_id(id.int()) or { return ctx.api_not_found() } + if pr.repo_id != repo.id { + return ctx.api_not_found() + } + comments := app.get_pr_comments(pr.id) + mut out := []ApiCommentView{cap: comments.len} + for c in comments { + author := app.get_username_by_id(c.author_id) or { '' } + out << ApiCommentView{ + id: c.id + author: author + text: c.text + created_at: c.created_at + } + } + return ctx.json(out) +} + +struct ApiDiscussionView { + id int + repo_id int + title string + body string + category string + author string + is_locked bool + is_answered bool + answer_id int + comments_count int + created_at int +} + +struct ApiMilestoneView { + id int + repo_id int + title string + description string + due_date int + is_closed bool + created_at int +} + +struct ApiProjectView { + id int + repo_id int + name string + description string + created_at int +} + +struct ApiProjectCardView { + id int + column_id int + title string + note string + position int + issue_id int + created_at int +} + +struct ApiProjectColumnView { + id int + project_id int + name string + position int + cards []ApiProjectCardView +} + +struct ApiProjectDetailView { + project ApiProjectView + columns []ApiProjectColumnView +} + +struct ApiWebhookView { + id int + repo_id int + url string + events []string + is_active bool + last_status int + last_delivery int + created_at int +} + +fn (mut app App) discussion_to_api(d Discussion) ApiDiscussionView { + author := app.get_username_by_id(d.author_id) or { '' } + return ApiDiscussionView{ + id: d.id + repo_id: d.repo_id + title: d.title + body: d.body + category: d.category + author: author + is_locked: d.is_locked + is_answered: d.is_answered + answer_id: d.answer_id + comments_count: d.comments_count + created_at: d.created_at + } +} + +fn (mut app App) milestone_to_api(m Milestone) ApiMilestoneView { + return ApiMilestoneView{ + id: m.id + repo_id: m.repo_id + title: m.title + description: m.description + due_date: m.due_date + is_closed: m.is_closed + created_at: m.created_at + } +} + +fn (mut app App) project_to_api(p Project) ApiProjectView { + return ApiProjectView{ + id: p.id + repo_id: p.repo_id + name: p.name + description: p.description + created_at: p.created_at + } +} + +fn (mut app App) project_card_to_api(c ProjectCard) ApiProjectCardView { + return ApiProjectCardView{ + id: c.id + column_id: c.column_id + title: c.title + note: c.note + position: c.position + issue_id: c.issue_id + created_at: c.created_at + } +} + +fn (w &Webhook) to_api() ApiWebhookView { + return ApiWebhookView{ + id: w.id + repo_id: w.repo_id + url: w.url + events: w.event_list() + is_active: w.is_active + last_status: w.last_status + last_delivery: w.last_delivery + created_at: w.created_at + } +} + +@['/api/v1/repos/:username/:repo_name/discussions'] +pub fn (mut app App) api_v1_repo_discussions(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + discussions := app.list_repo_discussions(repo.id) + mut out := []ApiDiscussionView{cap: discussions.len} + for d in discussions { + out << app.discussion_to_api(d) + } + return ctx.json(out) +} + +@['/api/v1/repos/:username/:repo_name/discussions/:id'] +pub fn (mut app App) api_v1_repo_discussion(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.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + discussion := app.find_discussion(id.int()) or { return ctx.api_not_found() } + if discussion.repo_id != repo.id { + return ctx.api_not_found() + } + return ctx.json(app.discussion_to_api(discussion)) +} + +@['/api/v1/repos/:username/:repo_name/discussions'; post] +pub fn (mut app App) api_v1_create_discussion(mut ctx Context, username string, repo_name string) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + if !repo.is_public && !app.has_user_repo_read_access(ctx, user.id, repo.id) { + return ctx.api_not_found() + } + title := ctx.form['title'] + body := ctx.form['body'] + raw_cat := ctx.form['category'] + if title == '' { + ctx.send_custom_error(400, 'Bad Request') + return ctx.json({ + 'success': 'false' + 'message': 'title is required' + }) + } + cat := if raw_cat in ['general', 'qa', 'announcement', 'idea'] { raw_cat } else { 'general' } + new_id := app.add_discussion(repo.id, user.id, title, body, cat) or { + ctx.send_custom_error(500, 'Internal Server Error') + return ctx.json({ + 'success': 'false' + 'message': 'failed to create discussion' + }) + } + discussion := app.find_discussion(new_id) or { return ctx.api_not_found() } + return ctx.json(app.discussion_to_api(discussion)) +} + +@['/api/v1/repos/:username/:repo_name/discussions/:id/comments'] +pub fn (mut app App) api_v1_discussion_comments(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.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + discussion := app.find_discussion(id.int()) or { return ctx.api_not_found() } + if discussion.repo_id != repo.id { + return ctx.api_not_found() + } + comments := app.get_discussion_comments(discussion.id) + mut out := []ApiCommentView{cap: comments.len} + for c in comments { + author := app.get_username_by_id(c.author_id) or { '' } + out << ApiCommentView{ + id: c.id + author: author + text: c.text + created_at: c.created_at + } + } + return ctx.json(out) +} + +@['/api/v1/repos/:username/:repo_name/discussions/:id/comments'; post] +pub fn (mut app App) api_v1_create_discussion_comment(mut ctx Context, username string, repo_name string, id string) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + if !repo.is_public && !app.has_user_repo_read_access(ctx, user.id, repo.id) { + return ctx.api_not_found() + } + discussion := app.find_discussion(id.int()) or { return ctx.api_not_found() } + if discussion.repo_id != repo.id { + return ctx.api_not_found() + } + if discussion.is_locked { + ctx.send_custom_error(403, 'Forbidden') + return ctx.json({ + 'success': 'false' + 'message': 'discussion is locked' + }) + } + text := ctx.form['text'] + if text == '' { + ctx.send_custom_error(400, 'Bad Request') + return ctx.json({ + 'success': 'false' + 'message': 'text is required' + }) + } + app.add_discussion_comment(discussion.id, user.id, text) or { + ctx.send_custom_error(500, 'Internal Server Error') + return ctx.json({ + 'success': 'false' + 'message': 'failed to add comment' + }) + } + return ctx.json({ + 'success': 'true' + }) +} + +@['/api/v1/repos/:username/:repo_name/milestones'] +pub fn (mut app App) api_v1_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.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + milestones := app.list_repo_milestones(repo.id) + mut out := []ApiMilestoneView{cap: milestones.len} + for m in milestones { + out << app.milestone_to_api(m) + } + return ctx.json(out) +} + +@['/api/v1/repos/:username/:repo_name/milestones/:id'] +pub fn (mut app App) api_v1_repo_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.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + milestone := app.find_milestone(id.int()) or { return ctx.api_not_found() } + if milestone.repo_id != repo.id { + return ctx.api_not_found() + } + return ctx.json(app.milestone_to_api(milestone)) +} + +@['/api/v1/repos/:username/:repo_name/milestones'; post] +pub fn (mut app App) api_v1_create_milestone(mut ctx Context, username string, repo_name string) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + if repo.user_id != user.id { + ctx.send_custom_error(403, 'Forbidden') + return ctx.json({ + 'success': 'false' + 'message': 'only the repo owner can create milestones' + }) + } + title := ctx.form['title'] + desc := ctx.form['description'] + due := parse_yyyy_mm_dd(ctx.form['due_date']) + if title == '' { + ctx.send_custom_error(400, 'Bad Request') + return ctx.json({ + 'success': 'false' + 'message': 'title is required' + }) + } + new_id := app.add_milestone(repo.id, title, desc, due) or { + ctx.send_custom_error(500, 'Internal Server Error') + return ctx.json({ + 'success': 'false' + 'message': 'failed to create milestone' + }) + } + milestone := app.find_milestone(new_id) or { return ctx.api_not_found() } + return ctx.json(app.milestone_to_api(milestone)) +} + +@['/api/v1/repos/:username/:repo_name/projects'] +pub fn (mut app App) api_v1_repo_projects(mut ctx Context, username string, repo_name string) veb.Result { + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + projects := app.list_repo_projects(repo.id) + mut out := []ApiProjectView{cap: projects.len} + for p in projects { + out << app.project_to_api(p) + } + return ctx.json(out) +} + +@['/api/v1/repos/:username/:repo_name/projects/:id'] +pub fn (mut app App) api_v1_repo_project(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.api_not_found() + } + caller := app.api_user_from_ctx(ctx) or { User{} } + if !repo.is_public && !app.has_user_repo_read_access(ctx, caller.id, repo.id) { + return ctx.api_not_found() + } + project := app.find_project(id.int()) or { return ctx.api_not_found() } + if project.repo_id != repo.id { + return ctx.api_not_found() + } + columns := app.list_project_columns(project.id) + mut col_views := []ApiProjectColumnView{cap: columns.len} + for col in columns { + cards := app.list_project_cards(col.id) + mut card_views := []ApiProjectCardView{cap: cards.len} + for c in cards { + card_views << app.project_card_to_api(c) + } + col_views << ApiProjectColumnView{ + id: col.id + project_id: col.project_id + name: col.name + position: col.position + cards: card_views + } + } + return ctx.json(ApiProjectDetailView{ + project: app.project_to_api(project) + columns: col_views + }) +} + +@['/api/v1/repos/:username/:repo_name/projects'; post] +pub fn (mut app App) api_v1_create_project(mut ctx Context, username string, repo_name string) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + if repo.user_id != user.id { + ctx.send_custom_error(403, 'Forbidden') + return ctx.json({ + 'success': 'false' + 'message': 'only the repo owner can create projects' + }) + } + name := ctx.form['name'] + desc := ctx.form['description'] + if name == '' { + ctx.send_custom_error(400, 'Bad Request') + return ctx.json({ + 'success': 'false' + 'message': 'name is required' + }) + } + new_id := app.add_project(repo.id, name, desc) or { + ctx.send_custom_error(500, 'Internal Server Error') + return ctx.json({ + 'success': 'false' + 'message': 'failed to create project' + }) + } + project := app.find_project(new_id) or { return ctx.api_not_found() } + return ctx.json(app.project_to_api(project)) +} + +@['/api/v1/repos/:username/:repo_name/webhooks'] +pub fn (mut app App) api_v1_repo_webhooks(mut ctx Context, username string, repo_name string) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + if repo.user_id != user.id { + return ctx.api_not_found() + } + hooks := app.list_repo_webhooks(repo.id) + mut out := []ApiWebhookView{cap: hooks.len} + for w in hooks { + out << w.to_api() + } + return ctx.json(out) +} + +@['/api/v1/repos/:username/:repo_name/webhooks/:id'] +pub fn (mut app App) api_v1_repo_webhook(mut ctx Context, username string, repo_name string, id string) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + if repo.user_id != user.id { + return ctx.api_not_found() + } + wh := app.find_webhook_by_id(id.int()) or { return ctx.api_not_found() } + if wh.repo_id != repo.id { + return ctx.api_not_found() + } + return ctx.json(wh.to_api()) +} + +@['/api/v1/repos/:username/:repo_name/webhooks'; post] +pub fn (mut app App) api_v1_create_webhook(mut ctx Context, username string, repo_name string) veb.Result { + user := app.api_user_from_ctx(ctx) or { return ctx.api_unauthorized() } + repo := app.find_repo_by_name_and_username(repo_name, username) or { + return ctx.api_not_found() + } + if repo.user_id != user.id { + ctx.send_custom_error(403, 'Forbidden') + return ctx.json({ + 'success': 'false' + 'message': 'only the repo owner can create webhooks' + }) + } + url := ctx.form['url'].trim_space() + secret := ctx.form['secret'] + events := ctx.form['events'].trim_space() + if url == '' || !(url.starts_with('http://') || url.starts_with('https://')) { + ctx.send_custom_error(400, 'Bad Request') + return ctx.json({ + 'success': 'false' + 'message': 'valid http(s) url is required' + }) + } + events_str := if events == '' { 'push,issue,pr,comment,release' } else { events } + app.add_webhook(repo.id, url, secret, events_str) or { + ctx.send_custom_error(500, 'Internal Server Error') + return ctx.json({ + 'success': 'false' + 'message': 'failed to create webhook' + }) + } + hooks := app.list_repo_webhooks(repo.id) + if hooks.len == 0 { + return ctx.api_not_found() + } + return ctx.json(hooks[0].to_api()) +} -- 2.39.5