-rw-r--r-- | Makefile | 1 | ||||
-rw-r--r-- | cgit.c | 8 | ||||
-rw-r--r-- | cgit.css | 76 | ||||
-rw-r--r-- | cgit.h | 3 | ||||
-rw-r--r-- | cgitrc.5.txt | 17 | ||||
-rw-r--r-- | cmd.c | 7 | ||||
-rw-r--r-- | shared.c | 1 | ||||
-rw-r--r-- | ui-shared.c | 15 | ||||
-rw-r--r-- | ui-shared.h | 5 | ||||
-rw-r--r-- | ui-stats.c | 410 | ||||
-rw-r--r-- | ui-stats.h | 27 | ||||
-rw-r--r-- | ui-tree.c | 3 |
12 files changed, 569 insertions, 4 deletions
@@ -81,24 +81,25 @@ OBJECTS += shared.o OBJECTS += ui-atom.o OBJECTS += ui-blob.o OBJECTS += ui-clone.o OBJECTS += ui-commit.o OBJECTS += ui-diff.o OBJECTS += ui-log.o OBJECTS += ui-patch.o OBJECTS += ui-plain.o OBJECTS += ui-refs.o OBJECTS += ui-repolist.o OBJECTS += ui-shared.o OBJECTS += ui-snapshot.o +OBJECTS += ui-stats.o OBJECTS += ui-summary.o OBJECTS += ui-tag.o OBJECTS += ui-tree.o ifdef NEEDS_LIBICONV EXTLIBS += -liconv endif .PHONY: all libgit test install uninstall clean force-version get-git all: cgit @@ -3,24 +3,25 @@ * Copyright (C) 2006 Lars Hjemli * * Licensed under GNU General Public License v2 * (see COPYING for full license text) */ #include "cgit.h" #include "cache.h" #include "cmd.h" #include "configfile.h" #include "html.h" #include "ui-shared.h" +#include "ui-stats.h" #include "scan-tree.h" const char *cgit_version = CGIT_VERSION; void config_cb(const char *name, const char *value) { if (!strcmp(name, "root-title")) ctx.cfg.root_title = xstrdup(value); else if (!strcmp(name, "root-desc")) ctx.cfg.root_desc = xstrdup(value); else if (!strcmp(name, "root-readme")) ctx.cfg.root_readme = xstrdup(value); @@ -45,24 +46,26 @@ void config_cb(const char *name, const char *value) if (!ctx.cfg.virtual_root && (!strcmp(value, "/"))) ctx.cfg.virtual_root = ""; } else if (!strcmp(name, "nocache")) ctx.cfg.nocache = atoi(value); else if (!strcmp(name, "snapshots")) ctx.cfg.snapshots = cgit_parse_snapshots_mask(value); else if (!strcmp(name, "enable-index-links")) ctx.cfg.enable_index_links = atoi(value); else if (!strcmp(name, "enable-log-filecount")) ctx.cfg.enable_log_filecount = atoi(value); else if (!strcmp(name, "enable-log-linecount")) ctx.cfg.enable_log_linecount = atoi(value); + else if (!strcmp(name, "max-stats")) + ctx.cfg.max_stats = cgit_find_stats_period(value, NULL); else if (!strcmp(name, "cache-size")) ctx.cfg.cache_size = atoi(value); else if (!strcmp(name, "cache-root")) ctx.cfg.cache_root = xstrdup(value); else if (!strcmp(name, "cache-root-ttl")) ctx.cfg.cache_root_ttl = atoi(value); else if (!strcmp(name, "cache-repo-ttl")) ctx.cfg.cache_repo_ttl = atoi(value); else if (!strcmp(name, "cache-static-ttl")) ctx.cfg.cache_static_ttl = atoi(value); else if (!strcmp(name, "cache-dynamic-ttl")) ctx.cfg.cache_dynamic_ttl = atoi(value); @@ -103,24 +106,26 @@ void config_cb(const char *name, const char *value) else if (ctx.repo && !strcmp(name, "repo.desc")) ctx.repo->desc = xstrdup(value); else if (ctx.repo && !strcmp(name, "repo.owner")) ctx.repo->owner = xstrdup(value); else if (ctx.repo && !strcmp(name, "repo.defbranch")) ctx.repo->defbranch = xstrdup(value); else if (ctx.repo && !strcmp(name, "repo.snapshots")) ctx.repo->snapshots = ctx.cfg.snapshots & cgit_parse_snapshots_mask(value); /* XXX: &? */ else if (ctx.repo && !strcmp(name, "repo.enable-log-filecount")) ctx.repo->enable_log_filecount = ctx.cfg.enable_log_filecount * atoi(value); else if (ctx.repo && !strcmp(name, "repo.enable-log-linecount")) ctx.repo->enable_log_linecount = ctx.cfg.enable_log_linecount * atoi(value); + else if (ctx.repo && !strcmp(name, "repo.max-stats")) + ctx.repo->max_stats = cgit_find_stats_period(value, NULL); else if (ctx.repo && !strcmp(name, "repo.module-link")) ctx.repo->module_link= xstrdup(value); else if (ctx.repo && !strcmp(name, "repo.readme") && value != NULL) { if (*value == '/') ctx.repo->readme = xstrdup(value); else ctx.repo->readme = xstrdup(fmt("%s/%s", ctx.repo->path, value)); } else if (!strcmp(name, "include")) parse_configfile(value, config_cb); } static void querystring_cb(const char *name, const char *value) @@ -149,47 +154,50 @@ static void querystring_cb(const char *name, const char *value) } else if (!strcmp(name, "ofs")) { ctx.qry.ofs = atoi(value); } else if (!strcmp(name, "path")) { ctx.qry.path = trim_end(value, '/'); } else if (!strcmp(name, "name")) { ctx.qry.name = xstrdup(value); } else if (!strcmp(name, "mimetype")) { ctx.qry.mimetype = xstrdup(value); } else if (!strcmp(name, "s")){ ctx.qry.sort = xstrdup(value); } else if (!strcmp(name, "showmsg")) { ctx.qry.showmsg = atoi(value); + } else if (!strcmp(name, "period")) { + ctx.qry.period = xstrdup(value); } } static void prepare_context(struct cgit_context *ctx) { memset(ctx, 0, sizeof(ctx)); ctx->cfg.agefile = "info/web/last-modified"; ctx->cfg.nocache = 0; ctx->cfg.cache_size = 0; ctx->cfg.cache_dynamic_ttl = 5; ctx->cfg.cache_max_create_time = 5; ctx->cfg.cache_repo_ttl = 5; ctx->cfg.cache_root = CGIT_CACHE_ROOT; ctx->cfg.cache_root_ttl = 5; ctx->cfg.cache_static_ttl = -1; ctx->cfg.css = "/cgit.css"; ctx->cfg.logo = "/git-logo.png"; ctx->cfg.local_time = 0; ctx->cfg.max_repo_count = 50; ctx->cfg.max_commit_count = 50; ctx->cfg.max_lock_attempts = 5; ctx->cfg.max_msg_len = 80; ctx->cfg.max_repodesc_len = 80; + ctx->cfg.max_stats = 0; ctx->cfg.module_link = "./?repo=%s&page=commit&id=%s"; ctx->cfg.renamelimit = -1; ctx->cfg.robots = "index, nofollow"; ctx->cfg.root_title = "Git repository browser"; ctx->cfg.root_desc = "a fast webinterface for the git dscm"; ctx->cfg.script_name = CGIT_SCRIPT_NAME; ctx->cfg.summary_branches = 10; ctx->cfg.summary_log = 10; ctx->cfg.summary_tags = 10; ctx->page.mimetype = "text/html"; ctx->page.charset = PAGE_ENCODING; ctx->page.filename = NULL; @@ -486,12 +486,88 @@ a.tag-deco { a.remote-deco { margin: 0px 0.5em; padding: 0px 0.25em; background-color: #ccccff; border: solid 1px #000077; } a.deco { margin: 0px 0.5em; padding: 0px 0.25em; background-color: #ff8888; border: solid 1px #770000; } +table.stats { + border: solid 1px black; + border-collapse: collapse; +} + +table.stats th { + text-align: left; + padding: 1px 0.5em; + background-color: #eee; + border: solid 1px black; +} + +table.stats td { + text-align: right; + padding: 1px 0.5em; + border: solid 1px black; +} + +table.stats td.total { + font-weight: bold; + text-align: left; +} + +table.stats td.sum { + color: #c00; + font-weight: bold; +/* background-color: #eee; */ +} + +table.stats td.left { + text-align: left; +} + +table.vgraph { + border-collapse: separate; + border: solid 1px black; + height: 200px; +} + +table.vgraph th { + background-color: #eee; + font-weight: bold; + border: solid 1px white; + padding: 1px 0.5em; +} + +table.vgraph td { + vertical-align: bottom; + padding: 0px 10px; +} + +table.vgraph div.bar { + background-color: #eee; +} + +table.hgraph { + border: solid 1px black; + width: 800px; +} + +table.hgraph th { + background-color: #eee; + font-weight: bold; + border: solid 1px black; + padding: 1px 0.5em; +} + +table.hgraph td { + vertical-align: center; + padding: 2px 2px; +} + +table.hgraph div.bar { + background-color: #eee; + height: 1em; +} @@ -52,24 +52,25 @@ struct cgit_repo { char *name; char *path; char *desc; char *owner; char *defbranch; char *group; char *module_link; char *readme; char *clone_url; int snapshots; int enable_log_filecount; int enable_log_linecount; + int max_stats; time_t mtime; }; struct cgit_repolist { int length; int count; struct cgit_repo *repos; }; struct commitinfo { struct commit *commit; char *author; @@ -111,24 +112,25 @@ struct cgit_query { char *raw; char *repo; char *page; char *search; char *grep; char *head; char *sha1; char *sha2; char *path; char *name; char *mimetype; char *url; + char *period; int ofs; int nohead; char *sort; int showmsg; }; struct cgit_config { char *agefile; char *cache_root; char *clone_prefix; char *css; char *favicon; @@ -151,24 +153,25 @@ struct cgit_config { int cache_repo_ttl; int cache_root_ttl; int cache_static_ttl; int enable_index_links; int enable_log_filecount; int enable_log_linecount; int local_time; int max_repo_count; int max_commit_count; int max_lock_attempts; int max_msg_len; int max_repodesc_len; + int max_stats; int nocache; int renamelimit; int snapshots; int summary_branches; int summary_log; int summary_tags; }; struct cgit_page { time_t modified; time_t expires; size_t size; diff --git a/cgitrc.5.txt b/cgitrc.5.txt index ab9ab66..09f56a6 100644 --- a/cgitrc.5.txt +++ b/cgitrc.5.txt @@ -120,24 +120,29 @@ max-commit-count max-message-length Specifies the maximum number of commit message characters to display in "log" view. Default value: "80". max-repo-count Specifies the number of entries to list per page on the repository index page. Default value: "50". max-repodesc-length Specifies the maximum number of repo description characters to display on the repository index page. Default value: "80". +max-stats + Set the default maximum statistics period. Valid values are "week", + "month", "quarter" and "year". If unspecified, statistics are + disabled. Default value: none. See also: "repo.max-stats". + module-link Text which will be used as the formatstring for a hyperlink when a submodule is printed in a directory listing. The arguments for the formatstring are the path and SHA1 of the submodule commit. Default value: "./?repo=%s&page=commit&id=%s" nocache If set to the value "1" caching will be disabled. This settings is deprecated, and will not be honored starting with cgit-1.0. Default value: "0". renamelimit @@ -209,24 +214,29 @@ repo.defbranch repo.desc The value to show as repository description. Default value: none. repo.enable-log-filecount A flag which can be used to disable the global setting `enable-log-filecount'. Default value: none. repo.enable-log-linecount A flag which can be used to disable the global setting `enable-log-linecount'. Default value: none. +repo.max-stats + Override the default maximum statistics period. Valid values are equal + to the values specified for the global "max-stats" setting. Default + value: none. + repo.name The value to show as repository name. Default value: <repo.url>. repo.owner A value used to identify the owner of the repository. Default value: none. repo.path An absolute path to the repository directory. For non-bare repositories this is the .git-directory. Default value: none. repo.readme @@ -267,24 +277,28 @@ enable-log-filecount=1 # Show number of added/removed lines per commit on the log pages enable-log-linecount=1 # Add a cgit favicon favicon=/favicon.ico # Use a custom logo logo=/img/mylogo.png +# Enable statistics per week, month and quarter +max-stats=quarter + + # Set the title and heading of the repository index page root-title=foobar.com git repositories # Set a subheading for the repository index page root-desc=tracking the foobar development # Include some more info about foobar.com on the index page root-readme=/var/www/htdocs/about.html @@ -339,24 +353,27 @@ repo.desc=the dscm repo.url=linux repo.path=/pub/git/linux.git repo.desc=the kernel # Disable adhoc downloads of this repo repo.snapshots=0 # Disable line-counts for this repo repo.enable-log-linecount=0 +# Restrict the max statistics period for this repo +repo.max-stats=month + BUGS ---- Comments currently cannot appear on the same line as a setting; the comment will be included as part of the value. E.g. this line: robots=index # allow indexing will generate the following html element: <meta name='robots' content='index # allow indexing'/> @@ -12,24 +12,25 @@ #include "ui-shared.h" #include "ui-atom.h" #include "ui-blob.h" #include "ui-clone.h" #include "ui-commit.h" #include "ui-diff.h" #include "ui-log.h" #include "ui-patch.h" #include "ui-plain.h" #include "ui-refs.h" #include "ui-repolist.h" #include "ui-snapshot.h" +#include "ui-stats.h" #include "ui-summary.h" #include "ui-tag.h" #include "ui-tree.h" static void HEAD_fn(struct cgit_context *ctx) { cgit_clone_head(ctx); } static void atom_fn(struct cgit_context *ctx) { cgit_print_atom(ctx->qry.head, ctx->qry.path, 10); @@ -99,24 +100,29 @@ static void plain_fn(struct cgit_context *ctx) static void refs_fn(struct cgit_context *ctx) { cgit_print_refs(); } static void snapshot_fn(struct cgit_context *ctx) { cgit_print_snapshot(ctx->qry.head, ctx->qry.sha1, ctx->qry.path, ctx->repo->snapshots, ctx->qry.nohead); } +static void stats_fn(struct cgit_context *ctx) +{ + cgit_show_stats(ctx); +} + static void summary_fn(struct cgit_context *ctx) { cgit_print_summary(); } static void tag_fn(struct cgit_context *ctx) { cgit_print_tag(ctx->qry.sha1); } static void tree_fn(struct cgit_context *ctx) { @@ -135,24 +141,25 @@ struct cgit_cmd *cgit_get_cmd(struct cgit_context *ctx) def_cmd(blob, 1, 0), def_cmd(commit, 1, 1), def_cmd(diff, 1, 1), def_cmd(info, 1, 0), def_cmd(log, 1, 1), def_cmd(ls_cache, 0, 0), def_cmd(objects, 1, 0), def_cmd(patch, 1, 0), def_cmd(plain, 1, 0), def_cmd(refs, 1, 1), def_cmd(repolist, 0, 0), def_cmd(snapshot, 1, 0), + def_cmd(stats, 1, 1), def_cmd(summary, 1, 1), def_cmd(tag, 1, 1), def_cmd(tree, 1, 1), }; int i; if (ctx->qry.page == NULL) { if (ctx->repo) ctx->qry.page = "summary"; else ctx->qry.page = "repolist"; } @@ -49,24 +49,25 @@ struct cgit_repo *cgit_add_repo(const char *url) ret = &cgit_repolist.repos[cgit_repolist.count-1]; ret->url = trim_end(url, '/'); ret->name = ret->url; ret->path = NULL; ret->desc = "[no description]"; ret->owner = NULL; ret->group = ctx.cfg.repo_group; ret->defbranch = "master"; ret->snapshots = ctx.cfg.snapshots; ret->enable_log_filecount = ctx.cfg.enable_log_filecount; ret->enable_log_linecount = ctx.cfg.enable_log_linecount; + ret->max_stats = ctx.cfg.max_stats; ret->module_link = ctx.cfg.module_link; ret->readme = NULL; ret->mtime = -1; return ret; } struct cgit_repo *cgit_get_repoinfo(const char *url) { int i; struct cgit_repo *repo; for (i=0; i<cgit_repolist.count; i++) { diff --git a/ui-shared.c b/ui-shared.c index fba1ba6..4f28512 100644 --- a/ui-shared.c +++ b/ui-shared.c @@ -360,24 +360,30 @@ void cgit_diff_link(char *name, char *title, char *class, char *head, } html("'>"); html_txt(name); html("</a>"); } void cgit_patch_link(char *name, char *title, char *class, char *head, char *rev) { reporevlink("patch", name, title, class, head, rev, NULL); } +void cgit_stats_link(char *name, char *title, char *class, char *head, + char *path) +{ + reporevlink("stats", name, title, class, head, NULL, path); +} + void cgit_object_link(struct object *obj) { char *page, *shortrev, *fullrev, *name; fullrev = sha1_to_hex(obj->sha1); shortrev = xstrdup(fullrev); shortrev[10] = '\0'; if (obj->type == OBJ_COMMIT) { cgit_commit_link(fmt("commit %s...", shortrev), NULL, NULL, ctx.qry.head, fullrev); return; } else if (obj->type == OBJ_TREE) @@ -548,25 +554,25 @@ int print_archive_ref(const char *refname, const unsigned char *sha1, html("<h1>download</h1>\n"); *header = 1; } url = cgit_pageurl(ctx.qry.repo, "blob", fmt("id=%s&path=%s", sha1_to_hex(fileid), buf)); html_link_open(url, NULL, "menu"); html_txt(strlpart(buf, 20)); html_link_close(); return 0; } -void add_hidden_formfields(int incl_head, int incl_search, char *page) +void cgit_add_hidden_formfields(int incl_head, int incl_search, char *page) { char *url; if (!ctx.cfg.virtual_root) { url = fmt("%s/%s", ctx.qry.repo, page); if (ctx.qry.path) url = fmt("%s/%s", url, ctx.qry.path); html_hidden("url", url); } if (incl_head && ctx.qry.head && ctx.repo->defbranch && strcmp(ctx.qry.head, ctx.repo->defbranch)) @@ -610,25 +616,25 @@ void cgit_print_pageheader(struct cgit_context *ctx) html_attr(cgit_rooturl()); html("'><img src='"); html_attr(ctx->cfg.logo); html("' alt='cgit logo'/></a></td>\n"); html("<td class='main'>"); if (ctx->repo) { cgit_index_link("index", NULL, NULL, NULL, 0); html(" : "); cgit_summary_link(ctx->repo->name, ctx->repo->name, NULL, NULL); html("</td><td class='form'>"); html("<form method='get' action=''>\n"); - add_hidden_formfields(0, 1, ctx->qry.page); + cgit_add_hidden_formfields(0, 1, ctx->qry.page); html("<select name='h' onchange='this.form.submit();'>\n"); for_each_branch_ref(print_branch_option, ctx->qry.head); html("</select> "); html("<input type='submit' name='' value='switch'/>"); html("</form>"); } else html_txt(ctx->cfg.root_title); html("</td></tr>\n"); html("<tr><td class='sub'>"); if (ctx->repo) { html_txt(ctx->repo->desc); @@ -647,35 +653,38 @@ void cgit_print_pageheader(struct cgit_context *ctx) cgit_summary_link("summary", NULL, hc(cmd, "summary"), ctx->qry.head); cgit_refs_link("refs", NULL, hc(cmd, "refs"), ctx->qry.head, ctx->qry.sha1, NULL); cgit_log_link("log", NULL, hc(cmd, "log"), ctx->qry.head, NULL, NULL, 0, NULL, NULL, ctx->qry.showmsg); cgit_tree_link("tree", NULL, hc(cmd, "tree"), ctx->qry.head, ctx->qry.sha1, NULL); cgit_commit_link("commit", NULL, hc(cmd, "commit"), ctx->qry.head, ctx->qry.sha1); cgit_diff_link("diff", NULL, hc(cmd, "diff"), ctx->qry.head, ctx->qry.sha1, ctx->qry.sha2, NULL); + if (ctx->repo->max_stats) + cgit_stats_link("stats", NULL, hc(cmd, "stats"), + ctx->qry.head, NULL); if (ctx->repo->readme) reporevlink("about", "about", NULL, hc(cmd, "about"), ctx->qry.head, NULL, NULL); html("</td><td class='form'>"); html("<form class='right' method='get' action='"); if (ctx->cfg.virtual_root) html_url_path(cgit_fileurl(ctx->qry.repo, "log", ctx->qry.path, NULL)); html("'>\n"); - add_hidden_formfields(1, 0, "log"); + cgit_add_hidden_formfields(1, 0, "log"); html("<select name='qt'>\n"); html_option("grep", "log msg", ctx->qry.grep); html_option("author", "author", ctx->qry.grep); html_option("committer", "committer", ctx->qry.grep); html("</select>\n"); html("<input class='txt' type='text' size='10' name='q' value='"); html_attr(ctx->qry.search); html("'/>\n"); html("<input type='submit' value='search'/>\n"); html("</form>\n"); } else { site_link(NULL, "index", NULL, hc(cmd, "repolist"), NULL, 0); diff --git a/ui-shared.h b/ui-shared.h index 2ab53ae..5a3821f 100644 --- a/ui-shared.h +++ b/ui-shared.h @@ -21,26 +21,29 @@ extern void cgit_log_link(char *name, char *title, char *class, char *head, char *rev, char *path, int ofs, char *grep, char *pattern, int showmsg); extern void cgit_commit_link(char *name, char *title, char *class, char *head, char *rev); extern void cgit_patch_link(char *name, char *title, char *class, char *head, char *rev); extern void cgit_refs_link(char *name, char *title, char *class, char *head, char *rev, char *path); extern void cgit_snapshot_link(char *name, char *title, char *class, char *head, char *rev, char *archivename); extern void cgit_diff_link(char *name, char *title, char *class, char *head, char *new_rev, char *old_rev, char *path); +extern void cgit_stats_link(char *name, char *title, char *class, char *head, + char *path); extern void cgit_object_link(struct object *obj); extern void cgit_print_error(char *msg); extern void cgit_print_date(time_t secs, char *format, int local_time); extern void cgit_print_age(time_t t, time_t max_relative, char *format); extern void cgit_print_http_headers(struct cgit_context *ctx); extern void cgit_print_docstart(struct cgit_context *ctx); extern void cgit_print_docend(); extern void cgit_print_pageheader(struct cgit_context *ctx); extern void cgit_print_filemode(unsigned short mode); extern void cgit_print_snapshot_links(const char *repo, const char *head, const char *hex, int snapshots); - +extern void cgit_add_hidden_formfields(int incl_head, int incl_search, + char *page); #endif /* UI_SHARED_H */ diff --git a/ui-stats.c b/ui-stats.c new file mode 100644 index 0000000..9fc06d3 --- a/dev/null +++ b/ui-stats.c @@ -0,0 +1,410 @@ +#include <string-list.h> + +#include "cgit.h" +#include "html.h" +#include "ui-shared.h" +#include "ui-stats.h" + +#define MONTHS 6 + +struct authorstat { + long total; + struct string_list list; +}; + +#define DAY_SECS (60 * 60 * 24) +#define WEEK_SECS (DAY_SECS * 7) + +static void trunc_week(struct tm *tm) +{ + time_t t = timegm(tm); + t -= ((tm->tm_wday + 6) % 7) * DAY_SECS; + gmtime_r(&t, tm); +} + +static void dec_week(struct tm *tm) +{ + time_t t = timegm(tm); + t -= WEEK_SECS; + gmtime_r(&t, tm); +} + +static void inc_week(struct tm *tm) +{ + time_t t = timegm(tm); + t += WEEK_SECS; + gmtime_r(&t, tm); +} + +static char *pretty_week(struct tm *tm) +{ + static char buf[10]; + + strftime(buf, sizeof(buf), "W%V %G", tm); + return buf; +} + +static void trunc_month(struct tm *tm) +{ + tm->tm_mday = 1; +} + +static void dec_month(struct tm *tm) +{ + tm->tm_mon--; + if (tm->tm_mon < 0) { + tm->tm_year--; + tm->tm_mon = 11; + } +} + +static void inc_month(struct tm *tm) +{ + tm->tm_mon++; + if (tm->tm_mon > 11) { + tm->tm_year++; + tm->tm_mon = 0; + } +} + +static char *pretty_month(struct tm *tm) +{ + static const char *months[] = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + }; + return fmt("%s %d", months[tm->tm_mon], tm->tm_year + 1900); +} + +static void trunc_quarter(struct tm *tm) +{ + trunc_month(tm); + while(tm->tm_mon % 3 != 0) + dec_month(tm); +} + +static void dec_quarter(struct tm *tm) +{ + dec_month(tm); + dec_month(tm); + dec_month(tm); +} + +static void inc_quarter(struct tm *tm) +{ + inc_month(tm); + inc_month(tm); + inc_month(tm); +} + +static char *pretty_quarter(struct tm *tm) +{ + return fmt("Q%d %d", tm->tm_mon / 3 + 1, tm->tm_year + 1900); +} + +static void trunc_year(struct tm *tm) +{ + trunc_month(tm); + tm->tm_mon = 0; +} + +static void dec_year(struct tm *tm) +{ + tm->tm_year--; +} + +static void inc_year(struct tm *tm) +{ + tm->tm_year++; +} + +static char *pretty_year(struct tm *tm) +{ + return fmt("%d", tm->tm_year + 1900); +} + +struct cgit_period periods[] = { + {'w', "week", 12, 4, trunc_week, dec_week, inc_week, pretty_week}, + {'m', "month", 12, 4, trunc_month, dec_month, inc_month, pretty_month}, + {'q', "quarter", 12, 4, trunc_quarter, dec_quarter, inc_quarter, pretty_quarter}, + {'y', "year", 12, 4, trunc_year, dec_year, inc_year, pretty_year}, +}; + +/* Given a period code or name, return a period index (1, 2, 3 or 4) + * and update the period pointer to the correcsponding struct. + * If no matching code is found, return 0. + */ +int cgit_find_stats_period(const char *expr, struct cgit_period **period) +{ + int i; + char code = '\0'; + + if (!expr) + return 0; + + if (strlen(expr) == 1) + code = expr[0]; + + for (i = 0; i < sizeof(periods) / sizeof(periods[0]); i++) + if (periods[i].code == code || !strcmp(periods[i].name, expr)) { + if (period) + *period = &periods[i]; + return i+1; + } + return 0; +} + +static void add_commit(struct string_list *authors, struct commit *commit, + struct cgit_period *period) +{ + struct commitinfo *info; + struct string_list_item *author, *item; + struct authorstat *authorstat; + struct string_list *items; + char *tmp; + struct tm *date; + time_t t; + + info = cgit_parse_commit(commit); + tmp = xstrdup(info->author); + author = string_list_insert(tmp, authors); + if (!author->util) + author->util = xcalloc(1, sizeof(struct authorstat)); + else + free(tmp); + authorstat = author->util; + items = &authorstat->list; + t = info->committer_date; + date = gmtime(&t); + period->trunc(date); + tmp = xstrdup(period->pretty(date)); + item = string_list_insert(tmp, items); + if (item->util) + free(tmp); + item->util++; + authorstat->total++; + cgit_free_commitinfo(info); +} + +static int cmp_total_commits(const void *a1, const void *a2) +{ + const struct string_list_item *i1 = a1; + const struct string_list_item *i2 = a2; + const struct authorstat *auth1 = i1->util; + const struct authorstat *auth2 = i2->util; + + return auth2->total - auth1->total; +} + +/* Walk the commit DAG and collect number of commits per author per + * timeperiod into a nested string_list collection. + */ +struct string_list collect_stats(struct cgit_context *ctx, + struct cgit_period *period) +{ + struct string_list authors; + struct rev_info rev; + struct commit *commit; + const char *argv[] = {NULL, ctx->qry.head, NULL, NULL, NULL, NULL}; + int argc = 3; + time_t now; + long i; + struct tm *tm; + char tmp[11]; + + time(&now); + tm = gmtime(&now); + period->trunc(tm); + for (i = 1; i < period->count; i++) + period->dec(tm); + strftime(tmp, sizeof(tmp), "%Y-%m-%d", tm); + argv[2] = xstrdup(fmt("--since=%s", tmp)); + if (ctx->qry.path) { + argv[3] = "--"; + argv[4] = ctx->qry.path; + argc += 2; + } + init_revisions(&rev, NULL); + rev.abbrev = DEFAULT_ABBREV; + rev.commit_format = CMIT_FMT_DEFAULT; + rev.no_merges = 1; + rev.verbose_header = 1; + rev.show_root_diff = 0; + setup_revisions(argc, argv, &rev, NULL); + prepare_revision_walk(&rev); + memset(&authors, 0, sizeof(authors)); + while ((commit = get_revision(&rev)) != NULL) { + add_commit(&authors, commit, period); + free(commit->buffer); + free_commit_list(commit->parents); + } + return authors; +} + +void print_combined_authorrow(struct string_list *authors, int from, int to, + const char *name, const char *leftclass, const char *centerclass, + const char *rightclass, struct cgit_period *period) +{ + struct string_list_item *author; + struct authorstat *authorstat; + struct string_list *items; + struct string_list_item *date; + time_t now; + long i, j, total, subtotal; + struct tm *tm; + char *tmp; + + time(&now); + tm = gmtime(&now); + period->trunc(tm); + for (i = 1; i < period->count; i++) + period->dec(tm); + + total = 0; + htmlf("<tr><td class='%s'>%s</td>", leftclass, + fmt(name, to - from + 1)); + for (j = 0; j < period->count; j++) { + tmp = period->pretty(tm); + period->inc(tm); + subtotal = 0; + for (i = from; i <= to; i++) { + author = &authors->items[i]; + authorstat = author->util; + items = &authorstat->list; + date = string_list_lookup(tmp, items); + if (date) + subtotal += (size_t)date->util; + } + htmlf("<td class='%s'>%d</td>", centerclass, subtotal); + total += subtotal; + } + htmlf("<td class='%s'>%d</td></tr>", rightclass, total); +} + +void print_authors(struct string_list *authors, int top, + struct cgit_period *period) +{ + struct string_list_item *author; + struct authorstat *authorstat; + struct string_list *items; + struct string_list_item *date; + time_t now; + long i, j, total; + struct tm *tm; + char *tmp; + + time(&now); + tm = gmtime(&now); + period->trunc(tm); + for (i = 1; i < period->count; i++) + period->dec(tm); + + html("<table class='stats'><tr><th>Author</th>"); + for (j = 0; j < period->count; j++) { + tmp = period->pretty(tm); + htmlf("<th>%s</th>", tmp); + period->inc(tm); + } + html("<th>Total</th></tr>\n"); + + if (top <= 0 || top > authors->nr) + top = authors->nr; + + for (i = 0; i < top; i++) { + author = &authors->items[i]; + html("<tr><td class='left'>"); + html_txt(author->string); + html("</td>"); + authorstat = author->util; + items = &authorstat->list; + total = 0; + for (j = 0; j < period->count; j++) + period->dec(tm); + for (j = 0; j < period->count; j++) { + tmp = period->pretty(tm); + period->inc(tm); + date = string_list_lookup(tmp, items); + if (!date) + html("<td>0</td>"); + else { + htmlf("<td>%d</td>", date->util); + total += (size_t)date->util; + } + } + htmlf("<td class='sum'>%d</td></tr>", total); + } + + if (top < authors->nr) + print_combined_authorrow(authors, top, authors->nr - 1, + "Others (%d)", "left", "", "sum", period); + + print_combined_authorrow(authors, 0, authors->nr - 1, "Total", + "total", "sum", "sum", period); + html("</table>"); +} + +/* Create a sorted string_list with one entry per author. The util-field + * for each author is another string_list which is used to calculate the + * number of commits per time-interval. + */ +void cgit_show_stats(struct cgit_context *ctx) +{ + struct string_list authors; + struct cgit_period *period; + int top, i; + const char *code = "w"; + + if (ctx->qry.period) + code = ctx->qry.period; + + i = cgit_find_stats_period(code, &period); + if (!i) { + cgit_print_error(fmt("Unknown statistics type: %c", code)); + return; + } + if (i > ctx->repo->max_stats) { + cgit_print_error(fmt("Statistics type disabled: %s", + period->name)); + return; + } + authors = collect_stats(ctx, period); + qsort(authors.items, authors.nr, sizeof(struct string_list_item), + cmp_total_commits); + + top = ctx->qry.ofs; + if (!top) + top = 10; + htmlf("<h2>Commits per author per %s", period->name); + if (ctx->qry.path) { + html(" (path '"); + html_txt(ctx->qry.path); + html("')"); + } + html("</h2>"); + + html("<form method='get' action='' style='float: right; text-align: right;'>"); + cgit_add_hidden_formfields(1, 0, "stats"); + if (ctx->repo->max_stats > 1) { + html("Period: "); + html("<select name='period' onchange='this.form.submit();'>"); + for (i = 0; i < ctx->repo->max_stats; i++) + htmlf("<option value='%c'%s>%s</option>", + periods[i].code, + period == &periods[i] ? " selected" : "", + periods[i].name); + html("</select><br/><br/>"); + } + html("Authors: "); + html(""); + html("<select name='ofs' onchange='this.form.submit();'>"); + htmlf("<option value='10'%s>10</option>", top == 10 ? " selected" : ""); + htmlf("<option value='25'%s>25</option>", top == 25 ? " selected" : ""); + htmlf("<option value='50'%s>50</option>", top == 50 ? " selected" : ""); + htmlf("<option value='100'%s>100</option>", top == 100 ? " selected" : ""); + htmlf("<option value='-1'%s>All</option>", top == -1 ? " selected" : ""); + html("</select>"); + html("<noscript> <input type='submit' value='Reload'/></noscript>"); + html("</form>"); + print_authors(&authors, top, period); +} + diff --git a/ui-stats.h b/ui-stats.h new file mode 100644 index 0000000..4f13dba --- a/dev/null +++ b/ui-stats.h @@ -0,0 +1,27 @@ +#ifndef UI_STATS_H +#define UI_STATS_H + +#include "cgit.h" + +struct cgit_period { + const char code; + const char *name; + int max_periods; + int count; + + /* Convert a tm value to the first day in the period */ + void (*trunc)(struct tm *tm); + + /* Update tm value to start of next/previous period */ + void (*dec)(struct tm *tm); + void (*inc)(struct tm *tm); + + /* Pretty-print a tm value */ + char *(*pretty)(struct tm *tm); +}; + +extern int cgit_find_stats_period(const char *expr, struct cgit_period **period); + +extern void cgit_show_stats(struct cgit_context *ctx); + +#endif /* UI_STATS_H */ @@ -100,24 +100,27 @@ static int ls_item(const unsigned char *sha1, const char *base, int baselen, } else if (S_ISDIR(mode)) { cgit_tree_link(name, NULL, "ls-dir", ctx.qry.head, curr_rev, fullpath); } else { cgit_tree_link(name, NULL, "ls-blob", ctx.qry.head, curr_rev, fullpath); } htmlf("</td><td class='ls-size'>%li</td>", size); html("<td>"); cgit_log_link("log", NULL, "button", ctx.qry.head, curr_rev, fullpath, 0, NULL, NULL, ctx.qry.showmsg); + if (ctx.repo->max_stats) + cgit_stats_link("stats", NULL, "button", ctx.qry.head, + fullpath); html("</td></tr>\n"); free(name); return 0; } static void ls_head() { html("<table summary='tree listing' class='list'>\n"); html("<tr class='nohover'>"); html("<th class='left'>Mode</th>"); html("<th class='left'>Name</th>"); html("<th class='right'>Size</th>"); |