diff options
| author | 2026-07-30 12:26:52 +0700 | |
|---|---|---|
| committer | 2026-07-30 12:26:52 +0700 | |
| commit | b4b4f2325db71041f38902143b28bae94762b111 (patch) | |
| tree | 8ad6f71551b249d7aa52fc864d586becd4ff34de | |
| parent | dbed2ecdf824d154f7d69a624013a02e6e0966be (diff) | |
| download | cgitext-b4b4f2325db71041f38902143b28bae94762b111.tar.gz cgitext-b4b4f2325db71041f38902143b28bae94762b111.zip | |
ui-ci: add a "ci" tab which redirects to an external ci system
Add a "ci" page which redirects to an external ci system for the ref
being viewed, along with a tab for it in the repository header.
The target is a url template, configurable globally or per repository,
with separate settings for branches and tags because pipelines for the
two commonly live at different locations: on a Jenkins multibranch
pipeline a branch is at job/<name>/job/<ref> while a tag is at
job/<name>/view/tags/job/<ref>. $ref, $repo and $slug are substituted,
$slug being the repository url with any ".git" suffix removed and slashes
replaced by dashes, which is the shape job names usually take.
An explicit repo.ci-url drops the branch and tag urls the repository
would otherwise inherit from the global settings, as it could never take
effect otherwise.
The expansion is written to the Location header verbatim rather than
through cgit_redirect(), which percent-encodes '?', '=' and '%' and would
corrupt any url carrying a query string. An expansion containing CR or LF
is refused so that it cannot smuggle in further headers.
Signed-off-by: Saya Andy <saya.andy@posteo.com>
| -rw-r--r-- | cgit.c | 22 | ||||
| -rw-r--r-- | cgit.h | 6 | ||||
| -rw-r--r-- | cgit.mk | 1 | ||||
| -rw-r--r-- | cgitrc.5.txt | 70 | ||||
| -rw-r--r-- | cmd.c | 7 | ||||
| -rw-r--r-- | shared.c | 3 | ||||
| -rw-r--r-- | tests/t0112-ci.sh | 83 | ||||
| -rw-r--r-- | ui-ci.c | 129 | ||||
| -rw-r--r-- | ui-ci.h | 7 | ||||
| -rw-r--r-- | ui-shared.c | 9 | ||||
| -rw-r--r-- | ui-shared.h | 2 |
11 files changed, 339 insertions, 0 deletions
@@ -56,6 +56,16 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu repo->owner = strdup_first_line(value); else if (!strcmp(name, "homepage")) repo->homepage = strdup_first_line(value); + else if (!strcmp(name, "ci-url")) { + repo->ci_url = strdup_first_line(value); + if (repo->ci_branch_url == ctx.cfg.ci_branch_url) + repo->ci_branch_url = NULL; + if (repo->ci_tag_url == ctx.cfg.ci_tag_url) + repo->ci_tag_url = NULL; + } else if (!strcmp(name, "ci-branch-url")) + repo->ci_branch_url = strdup_first_line(value); + else if (!strcmp(name, "ci-tag-url")) + repo->ci_tag_url = strdup_first_line(value); else if (!strcmp(name, "defbranch")) repo->defbranch = strdup_first_line(value); else if (!strcmp(name, "extra-head-content")) @@ -163,6 +173,12 @@ static void config_cb(const char *name, const char *value) ctx.cfg.logo_link = strdup_first_line(value); else if (!strcmp(name, "module-link")) ctx.cfg.module_link = strdup_first_line(value); + else if (!strcmp(name, "ci-url")) + ctx.cfg.ci_url = strdup_first_line(value); + else if (!strcmp(name, "ci-branch-url")) + ctx.cfg.ci_branch_url = strdup_first_line(value); + else if (!strcmp(name, "ci-tag-url")) + ctx.cfg.ci_tag_url = strdup_first_line(value); else if (!strcmp(name, "strict-export")) ctx.cfg.strict_export = strdup_first_line(value); else if (!strcmp(name, "virtual-root")) @@ -815,6 +831,12 @@ static void print_repo(FILE *f, struct cgit_repo *repo) fprintf(f, "repo.section=%s\n", repo->section); if (repo->homepage) fprintf(f, "repo.homepage=%s\n", repo->homepage); + if (repo->ci_url) + fprintf(f, "repo.ci-url=%s\n", repo->ci_url); + if (repo->ci_branch_url) + fprintf(f, "repo.ci-branch-url=%s\n", repo->ci_branch_url); + if (repo->ci_tag_url) + fprintf(f, "repo.ci-tag-url=%s\n", repo->ci_tag_url); if (repo->clone_url) fprintf(f, "repo.clone-url=%s\n", repo->clone_url); fprintf(f, "repo.enable-blame=%d\n", @@ -90,6 +90,9 @@ struct cgit_repo { char *extra_head_content; char *owner; char *homepage; + char *ci_url; + char *ci_branch_url; + char *ci_tag_url; char *defbranch; char *module_link; struct string_list readme; @@ -196,6 +199,9 @@ struct cgit_query { struct cgit_config { char *agefile; char *cache_root; + char *ci_url; + char *ci_branch_url; + char *ci_tag_url; char *clone_prefix; char *clone_url; char *favicon; @@ -79,6 +79,7 @@ CGIT_OBJ_NAMES += shared.o CGIT_OBJ_NAMES += ui-atom.o CGIT_OBJ_NAMES += ui-blame.o CGIT_OBJ_NAMES += ui-blob.o +CGIT_OBJ_NAMES += ui-ci.o CGIT_OBJ_NAMES += ui-clone.o CGIT_OBJ_NAMES += ui-commit.o CGIT_OBJ_NAMES += ui-diff.o diff --git a/cgitrc.5.txt b/cgitrc.5.txt index 7c39bf9..145a17e 100644 --- a/cgitrc.5.txt +++ b/cgitrc.5.txt @@ -100,6 +100,23 @@ cache-static-ttl:: version of repository pages accessed with a fixed SHA1. See also: "CACHE". Default value: -1". +ci-branch-url:: + Url template used by the "ci" tab when a branch is being viewed. If + unspecified, `ci-url` is used instead. Default value: none. See also: + "CI URL EXPANSION". + +ci-tag-url:: + Url template used by the "ci" tab when a tag is being viewed. If + unspecified, `ci-url' is used instead. Default value: none. See also: + "CI URL EXPANSION". + +ci-url:: + Url template used by the "ci" tab for refs which are not covered by + `ci-branch-url` or `ci-tag-url`. When at least one of the three ci url + settings is specified, cgit adds a "ci" tab which redirects to the + expanded url for the ref currently being viewed. Default value: none. + See also: "CI URL EXPANSION". + clone-prefix:: Space-separated list of common prefixes which, when combined with a repository url, generates valid clone urls for the repository. This @@ -468,6 +485,18 @@ repo.branch-sort:: list, and when set to "name" enables ordering by branch name. Default value: "name". +repo.ci-branch-url:: + Override the global setting `ci-branch-url' for this repository. + Default value: <ci-branch-url>. See also: "CI URL EXPANSION". + +repo.ci-tag-url:: + Override the global setting `ci-tag-url' for this repository. Default + value: <ci-tag-url>. See also: "CI URL EXPANSION". + +repo.ci-url:: + Override the global setting `ci-url' for this repository. Default + value: <ci-url>. See also: "CI URL EXPANSION". + repo.clone-url:: A list of space-separated urls which can be used to clone this repo. Default value: none. See also: "MACRO EXPANSION". @@ -776,6 +805,42 @@ the environment variables defined in "FILTER API": - repo.clone-url +CI URL EXPANSION +---------------- +The options `ci-url', `ci-branch-url' and `ci-tag-url' (and their +`repo.'-prefixed counterparts) are url templates in which the following +tokens are substituted when the "ci" tab is followed: + +$ref:: + The name of the branch or tag currently being viewed. + +$repo:: + The url of the repository, i.e. the value of `repo.url'. + +$slug:: + $repo with any ".git" suffix removed and all slashes replaced by + dashes, e.g. "sayauz/web.git" becomes "sayauz-web". + +Two consecutive dollar signs produce a single literal one, and any other +"$" is left untouched. Tokens are substituted verbatim, so the resulting +url must already be properly encoded. + +When a tag is being viewed, `ci-tag-url' is used; for anything else +`ci-branch-url' is used. Whichever of the two applies falls back to +`ci-url' when it is unset, and the "ci" tab yields "404 Not found" when +neither is set for the current ref. This makes it possible to point at a +Jenkins multibranch pipeline, where branches and tags live at different +locations: + + ci-branch-url=https://ci.example.org/job/$slug/job/$ref + ci-tag-url=https://ci.example.org/job/$slug/view/tags/job/$ref + +Setting `repo.ci-url' discards the values which the repository would +otherwise inherit from the global `ci-branch-url' and `ci-tag-url', so +that a single repository can be pointed at a different ci system without +having to override both of them. + + CACHE ----- @@ -828,6 +893,11 @@ clone-url=git://foo.org/$CGIT_REPO_URL git@foo.org:$CGIT_REPO_URL css=/css/cgit.css +# Add a "ci" tab pointing at a Jenkins multibranch pipeline +ci-branch-url=https://ci.foo.org/job/$slug/job/$ref +ci-tag-url=https://ci.foo.org/job/$slug/view/tags/job/$ref + + # Show owner on index page enable-index-owner=1 @@ -13,6 +13,7 @@ #include "ui-atom.h" #include "ui-blame.h" #include "ui-blob.h" +#include "ui-ci.h" #include "ui-clone.h" #include "ui-commit.h" #include "ui-diff.h" @@ -77,6 +78,11 @@ static void blob_fn(void) cgit_print_blob(ctx.qry.oid, ctx.qry.path, ctx.qry.head, 0); } +static void ci_fn(void) +{ + cgit_print_ci(); +} + static void commit_fn(void) { cgit_print_commit(ctx.qry.oid, ctx.qry.path); @@ -175,6 +181,7 @@ struct cgit_cmd *cgit_get_cmd(void) def_cmd(about, 0, 0, 0), def_cmd(blame, 1, 1, 0), def_cmd(blob, 1, 0, 0), + def_cmd(ci, 1, 0, 0), def_cmd(commit, 1, 1, 0), def_cmd(diff, 1, 1, 0), def_cmd(info, 1, 0, 1), @@ -71,6 +71,9 @@ struct cgit_repo *cgit_add_repo(const char *url) ret->extra_head_content = NULL; ret->owner = NULL; ret->homepage = NULL; + ret->ci_url = ctx.cfg.ci_url; + ret->ci_branch_url = ctx.cfg.ci_branch_url; + ret->ci_tag_url = ctx.cfg.ci_tag_url; ret->section = ctx.cfg.section; ret->snapshots = ctx.cfg.snapshots; ret->enable_blame = ctx.cfg.enable_blame; diff --git a/tests/t0112-ci.sh b/tests/t0112-ci.sh new file mode 100644 index 0000000..d8673e4 --- /dev/null +++ b/tests/t0112-ci.sh @@ -0,0 +1,83 @@ +#!/bin/sh + +test_description='Check content on ci page' +. ./setup.sh + +# The ci settings are only inherited by repositories which are declared +# after them, which is used below to get a repository with no ci url and +# one with a tag-only ci url. +cat >cgitrc.ci <<EOF +virtual-root=/ +cache-root=$PWD/cache +cache-size=0 + +repo.url=tagonly +repo.path=$PWD/repos/foo/.git +repo.ci-tag-url=https://ci.example.org/tagonly/\$ref + +ci-branch-url=https://ci.example.org/job/\$slug/job/\$ref +ci-tag-url=https://ci.example.org/job/\$slug/view/tags/job/\$ref + +repo.url=foo +repo.path=$PWD/repos/foo/.git + +repo.url=sayauz/web.git +repo.path=$PWD/repos/foo/.git + +repo.url=override +repo.path=$PWD/repos/foo/.git +repo.ci-url=https://ci.example.org/override/\$repo/\$\$/\$ref +EOF + +git --git-dir="$PWD/repos/foo/.git" tag v0.17.3.2 master + +cgit_ci() +{ + CGIT_CONFIG="$PWD/cgitrc.ci" QUERY_STRING="url=$1" cgit +} + +test_expect_success 'redirect to the branch ci url' ' + cgit_ci "foo/ci&h=master" >tmp && + grep "^Status: 302 Found$" tmp && + grep "^Location: https://ci.example.org/job/foo/job/master$" tmp +' + +test_expect_success 'redirect to the tag ci url' ' + cgit_ci "foo/ci&h=v0.17.3.2" >tmp && + grep "^Status: 302 Found$" tmp && + grep "^Location: https://ci.example.org/job/foo/view/tags/job/v0.17.3.2$" tmp +' + +test_expect_success 'redirect to the default branch ci url' ' + cgit_ci "foo/ci" >tmp && + grep "^Location: https://ci.example.org/job/foo/job/master$" tmp +' + +test_expect_success '$slug strips ".git" and replaces slashes' ' + cgit_ci "sayauz/web.git/ci&h=v0.17.3.2" >tmp && + grep "^Location: https://ci.example.org/job/sayauz-web/view/tags/job/v0.17.3.2$" tmp +' + +test_expect_success 'repo.ci-url overrides and expands $repo and $$' ' + cgit_ci "override/ci&h=master" >tmp && + grep -F "Location: https://ci.example.org/override/override/\$/master" tmp +' + +test_expect_success 'repo.ci-tag-url does not apply to branches' ' + cgit_ci "tagonly/ci&h=v0.17.3.2" >tmp && + grep "^Location: https://ci.example.org/tagonly/v0.17.3.2$" tmp && + cgit_ci "tagonly/ci&h=master" >tmp && + grep "^Status: 404 Not found$" tmp +' + +test_expect_success 'find ci tab' ' + cgit_ci "foo/refs&h=master" >tmp && + grep "href=./foo/ci/.>ci</a>" tmp +' + +test_expect_success 'no ci tab without a ci url' ' + cgit_url "foo/refs" >tmp && + ! grep ">ci</a>" tmp +' + +test_done @@ -0,0 +1,129 @@ +/* ui-ci.c: redirect to an external CI system + * + * Copyright (C) 2006-2025 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-ci.h" +#include "html.h" +#include "ui-shared.h" + +/* Memoized outcome of resolving the ci url for this request: -1 while + * unresolved, 0 when there is no ci url for the current ref, and 1 when + * ci_url holds the url to redirect to. + */ +static int ci_resolved = -1; +static char *ci_url; + +static int ref_is_tag(const char *ref) +{ + struct strbuf fullref = STRBUF_INIT; + struct object_id oid; + int is_tag; + + strbuf_addf(&fullref, "refs/tags/%s", ref); + is_tag = !repo_get_oid(the_repository, fullref.buf, &oid); + strbuf_release(&fullref); + return is_tag; +} + +/* Append the repository url with any ".git" suffix removed and all + * slashes replaced by dashes, e.g. "sayauz/web.git" becomes + * "sayauz-web". + */ +static void add_repo_slug(struct strbuf *buf, const char *url) +{ + size_t len, i; + + if (!strip_suffix(url, ".git", &len)) + len = strlen(url); + for (i = 0; i < len; i++) + strbuf_addch(buf, url[i] == '/' ? '-' : url[i]); +} + +static char *expand_ci_url(const char *template) +{ + const char *p, *rest; + struct strbuf buf = STRBUF_INIT; + + for (p = template; *p; p++) { + if (*p != '$') { + strbuf_addch(&buf, *p); + continue; + } + if (skip_prefix(p, "$ref", &rest)) + strbuf_addstr(&buf, ctx.qry.head); + else if (skip_prefix(p, "$repo", &rest)) + strbuf_addstr(&buf, ctx.repo->url); + else if (skip_prefix(p, "$slug", &rest)) + add_repo_slug(&buf, ctx.repo->url); + else if (skip_prefix(p, "$$", &rest)) + strbuf_addch(&buf, '$'); + else { + strbuf_addch(&buf, '$'); + continue; + } + p = rest - 1; + } + return strbuf_detach(&buf, NULL); +} + +static void resolve_ci_url(void) +{ + const char *template; + + if (ci_resolved != -1) + return; + ci_resolved = 0; + + if (!ctx.repo || !ctx.qry.head || !cgit_have_repository()) + return; + + if (ref_is_tag(ctx.qry.head)) + template = ctx.repo->ci_tag_url; + else + template = ctx.repo->ci_branch_url; + if (!template) + template = ctx.repo->ci_url; + if (!template) + return; + + ci_url = expand_ci_url(template); + ci_resolved = 1; +} + +int cgit_ci_available(void) +{ + resolve_ci_url(); + return ci_resolved == 1; +} + +void cgit_print_ci(void) +{ + resolve_ci_url(); + + if (ci_resolved != 1) { + cgit_print_error_page(404, "Not found", + "No ci url available for %s", + ctx.qry.head); + return; + } + + /* The expanded template is emitted verbatim rather than through + * cgit_redirect(), which percent-encodes characters such as '?', + * '=' and '%' that are meaningful in a ci url. Refuse anything + * which could be used to smuggle in extra response headers. + */ + if (strpbrk(ci_url, "\r\n")) { + cgit_print_error_page(500, "Internal server error", + "Malformed ci url"); + return; + } + + htmlf("Status: 302 Found\nLocation: %s\n\n", ci_url); +} @@ -0,0 +1,7 @@ +#ifndef UI_CI_H +#define UI_CI_H + +extern int cgit_ci_available(void); +extern void cgit_print_ci(void); + +#endif /* UI_CI_H */ diff --git a/ui-shared.c b/ui-shared.c index a6a828d..9a9d014 100644 --- a/ui-shared.c +++ b/ui-shared.c @@ -12,6 +12,7 @@ #include "ui-shared.h" #include "cmd.h" #include "html.h" +#include "ui-ci.h" #include "version.h" static const char cgit_doctype[] = @@ -342,6 +343,12 @@ void cgit_tag_link(const char *name, const char *title, const char *class, reporevlink("tag", name, title, class, tag, NULL, NULL); } +void cgit_ci_link(const char *name, const char *title, const char *class, + const char *head) +{ + reporevlink("ci", name, title, class, head, NULL, NULL); +} + void cgit_tree_link(const char *name, const char *title, const char *class, const char *head, const char *rev, const char *path) { @@ -1110,6 +1117,8 @@ void cgit_print_pageheader(void) if (ctx.repo->max_stats) cgit_stats_link("stats", NULL, hc("stats"), ctx.qry.head, ctx.qry.vpath); + if (cgit_ci_available()) + cgit_ci_link("ci", NULL, hc("ci"), ctx.qry.head); if (ctx.repo->homepage) { html("<a href='"); html_attr(ctx.repo->homepage); diff --git a/ui-shared.h b/ui-shared.h index 2a3a7f5..a6627d4 100644 --- a/ui-shared.h +++ b/ui-shared.h @@ -21,6 +21,8 @@ extern void cgit_summary_link(const char *name, const char *title, const char *class, const char *head); extern void cgit_tag_link(const char *name, const char *title, const char *class, const char *tag); +extern void cgit_ci_link(const char *name, const char *title, + const char *class, const char *head); extern void cgit_tree_link(const char *name, const char *title, const char *class, const char *head, const char *rev, const char *path); |