aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoseph Hunkeler <jhunkeler@users.noreply.github.com>2024-07-06 09:49:51 -0400
committerGitHub <noreply@github.com>2024-07-06 09:49:51 -0400
commit8ce824ac4b2f526331093a7150e643700efd4d20 (patch)
tree99e27dee24e82d78941ded4c510e1bac57c3f015 /src
parentabe87056faa6ed02aff3bbf77c1fd78b713a0864 (diff)
downloadstasis-8ce824ac4b2f526331093a7150e643700efd4d20.tar.gz
Add github.c and github.h (#9)
* Add github.c and github.h * Implements get_github_release_notes() * Remove unused variables * Fix circular dependency on tplfunc_frame * Remove predeclaration of tplfunc_frame * tpl_register_func accepts pointer to void instead * tpl_register_func sets maximum number of arguments * Frame is generated within tpl_register_func * Improve template function error handling and return/output management * Remove redundant extern statement * Include github.h and template_func_proto.h in core.h * Expose get_github_release_notes_tplfunc_entrypoint function to template engine * Add template_func_proto.c and template_func_proto.h * Replace free() with guard variant * Fix test_template::test_tpl_register_func * Fix tests * Fix tests * cmd should be at least PATH_MAX in size. * Magic number caused failure to install conda with a long installation path * Implement get_github_release_notes_auto function that bases release note data off test contexts * Disable overwriting releases by default * Add automatic release note generation function call to release_notes.md.in * Fix test_tpl_register_func() * Add enough space for tar command plus a path * Fix circular include * Github functions do not require access to core.h anyway * Add comments to union * Update README to mention template function availability * Add EnvCtl structure * Add runtime checks to avoid running all the way to the end only to be met with a configuration error. * Rename GITHUB to GH * Development docs pre-rough-draft
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt2
-rw-r--r--src/conda.c2
-rw-r--r--src/delivery.c22
-rw-r--r--src/github.c133
-rw-r--r--src/globals.c16
-rw-r--r--src/ini.c2
-rw-r--r--src/junitxml.c6
-rw-r--r--src/stasis_main.c49
-rw-r--r--src/template.c31
-rw-r--r--src/template_func_proto.c65
10 files changed, 307 insertions, 21 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index c42bb0f..a7b06f7 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -22,6 +22,8 @@ add_library(stasis_core STATIC
rules.c
docker.c
junitxml.c
+ github.c
+ template_func_proto.c
)
add_executable(stasis
diff --git a/src/conda.c b/src/conda.c
index 1e8b03f..976bbbc 100644
--- a/src/conda.c
+++ b/src/conda.c
@@ -32,7 +32,7 @@ int micromamba(struct MicromambaInfo *info, char *command, ...) {
sprintf(mmbin, "%s/micromamba", info->micromamba_prefix);
if (access(mmbin, F_OK)) {
- char untarcmd[PATH_MAX];
+ char untarcmd[PATH_MAX * 2];
mkdirs(info->micromamba_prefix, 0755);
sprintf(untarcmd, "tar -xvf %s -C %s --strip-components=1 bin/micromamba 1>/dev/null", installer_path, info->micromamba_prefix);
system(untarcmd);
diff --git a/src/delivery.c b/src/delivery.c
index 278647c..7bedef3 100644
--- a/src/delivery.c
+++ b/src/delivery.c
@@ -1419,7 +1419,7 @@ void delivery_install_conda(char *install_script, char *conda_install_dir) {
// Proceed with the installation
// -b = batch mode (non-interactive)
- char cmd[255] = {0};
+ char cmd[PATH_MAX] = {0};
snprintf(cmd, sizeof(cmd) - 1, "%s %s -b -p %s",
find_program("bash"),
install_script,
@@ -1476,7 +1476,7 @@ void delivery_defer_packages(struct Delivery *ctx, int type) {
struct StrList *filtered = NULL;
filtered = strlist_init();
- for (size_t i = 0, z = 0; i < strlist_count(dataptr); i++) {
+ for (size_t i = 0; i < strlist_count(dataptr); i++) {
int ignore_pkg = 0;
name = strlist_item(dataptr, i);
@@ -1494,7 +1494,6 @@ void delivery_defer_packages(struct Delivery *ctx, int type) {
if (strstr(name, ctx->tests[x].name)) {
version = ctx->tests[x].version;
ignore_pkg = 1;
- z++;
break;
}
}
@@ -2164,4 +2163,21 @@ int delivery_fixup_test_results(struct Delivery *ctx) {
closedir(dp);
return 0;
+}
+
+int delivery_exists(struct Delivery *ctx) {
+ // TODO: scan artifactory repo for the same information
+ char release_pattern[PATH_MAX] = {0};
+ sprintf(release_pattern, "*%s*", ctx->info.release_name);
+ struct StrList *files = listdir(ctx->storage.delivery_dir);
+ for (size_t i = 0; i < strlist_count(files); i++) {
+ char *filename = strlist_item(files, i);
+ int release_exists = fnmatch(release_pattern, filename, FNM_PATHNAME);
+ if (!globals.enable_overwrite && !release_exists) {
+ guard_strlist_free(&files);
+ return 1;
+ }
+ }
+ guard_strlist_free(&files);
+ return 0;
} \ No newline at end of file
diff --git a/src/github.c b/src/github.c
new file mode 100644
index 0000000..36e2e7c
--- /dev/null
+++ b/src/github.c
@@ -0,0 +1,133 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include "core.h"
+
+struct GHContent {
+ char *data;
+ size_t len;
+};
+
+static size_t writer(void *contents, size_t size, size_t nmemb, void *result) {
+ const size_t newlen = size * nmemb;
+ struct GHContent *content = (struct GHContent *) result;
+
+ char *ptr = realloc(content->data, content->len + newlen + 1);
+ if (!ptr) {
+ perror("realloc failed");
+ return 0;
+ }
+
+ content->data = ptr;
+ memcpy(&(content->data[content->len]), contents, newlen);
+ content->len += newlen;
+ content->data[content->len] = 0;
+
+ return newlen;
+}
+
+static char *unescape_lf(char *value) {
+ char *seq = strstr(value, "\\n");
+ while (seq != NULL) {
+ size_t cur_len = strlen(seq);
+ memmove(seq, seq + 1, strlen(seq) - 1);
+ *seq = '\n';
+ if (strlen(seq) && cur_len) {
+ seq[cur_len - 1] = 0;
+ }
+ seq = strstr(value, "\\n");
+ }
+ return value;
+}
+
+int get_github_release_notes(const char *api_token, const char *repo, const char *tag, const char *target_commitish, char **output) {
+ const char *field_body = "\"body\":\"";
+ const char *field_message = "\"message\":\"";
+ const char *endpoint_header_auth_fmt = "Authorization: Bearer %s";
+ const char *endpoint_header_api_version = "X-GitHub-Api-Version: " STASIS_GITHUB_API_VERSION;
+ const char *endpoint_post_fields_fmt = "{\"tag_name\":\"%s\", \"target_commitish\":\"%s\"}";
+ const char *endpoint_url_fmt = "https://api.github.com/repos/%s/releases/generate-notes";
+ char endpoint_header_auth[PATH_MAX] = {0};
+ char endpoint_post_fields[PATH_MAX] = {0};
+ char endpoint_url[PATH_MAX] = {0};
+ struct curl_slist *list = NULL;
+ struct GHContent content;
+
+ CURL *curl = curl_easy_init();
+ if (!curl) {
+ return -1;
+ }
+
+ // Render the header data
+ sprintf(endpoint_header_auth, endpoint_header_auth_fmt, api_token);
+ sprintf(endpoint_post_fields, endpoint_post_fields_fmt, tag, target_commitish);
+ sprintf(endpoint_url, endpoint_url_fmt, repo);
+
+ // Begin curl configuration
+ curl_easy_setopt(curl, CURLOPT_URL, endpoint_url);
+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+ curl_easy_setopt(curl, CURLOPT_POSTFIELDS, endpoint_post_fields);
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writer);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *) &content);
+
+ // Append headers to the request
+ list = curl_slist_append(list, "Accept: application/vnd.github+json");
+ list = curl_slist_append(list, endpoint_header_auth);
+ list = curl_slist_append(list, endpoint_header_api_version);
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
+
+ // Set the user-agent (github requires one)
+ char user_agent[20] = {0};
+ sprintf(user_agent, "stasis/%s", VERSION);
+ curl_easy_setopt(curl, CURLOPT_USERAGENT, user_agent);
+
+ // Execute curl request
+ memset(&content, 0, sizeof(content));
+ CURLcode res;
+ res = curl_easy_perform(curl);
+
+ // Clean up
+ curl_slist_free_all(list);
+ curl_easy_cleanup(curl);
+
+ if(res != CURLE_OK) {
+ fprintf(stderr, "curl_easy_perform() failed: %s\n",
+ curl_easy_strerror(res));
+ return -1;
+ }
+
+ // Replace all "\\n" literals with new line characters
+ char *line = unescape_lf(content.data);
+ if (line) {
+ char *data_offset = NULL;
+ if ((data_offset = strstr(line, field_body))) {
+ // Skip past the body field
+ data_offset += strlen(field_body);
+ // Remove quotation mark (and trailing comma if it exists)
+ int trim = 2;
+ char last_char = data_offset[strlen(data_offset) - trim];
+ if (last_char == ',') {
+ trim++;
+ }
+ data_offset[strlen(data_offset) - trim] = 0;
+ // Extract release notes
+ *output = strdup(data_offset);
+ } else if ((data_offset = strstr(line, field_message))) {
+ // Skip past the message field
+ data_offset += strlen(field_message);
+ *(strchr(data_offset, '"')) = 0;
+ fprintf(stderr, "GitHub API Error: '%s'\n", data_offset);
+ fprintf(stderr, "URL: %s\n", endpoint_url);
+ fprintf(stderr, "POST: %s\n", endpoint_post_fields);
+ guard_free(content.data);
+ return -1;
+ }
+ } else {
+ fprintf(stderr, "Unknown error\n");
+ guard_free(content.data);
+ return -1;
+ }
+
+ guard_free(content.data);
+ return 0;
+} \ No newline at end of file
diff --git a/src/globals.c b/src/globals.c
index 297598f..18a32b5 100644
--- a/src/globals.c
+++ b/src/globals.c
@@ -36,6 +36,22 @@ struct STASIS_GLOBAL globals = {
.enable_docker = true,
.enable_artifactory = true,
.enable_testing = true,
+ .envctl = {
+ {.flags = STASIS_ENVCTL_PASSTHRU, .name = {"TMPDIR", NULL}},
+ {.flags = STASIS_ENVCTL_PASSTHRU, .name = {"STASIS_ROOT", NULL}},
+ {.flags = STASIS_ENVCTL_PASSTHRU, .name = {"STASIS_SYSCONFDIR", NULL}},
+ {.flags = STASIS_ENVCTL_PASSTHRU, .name = {"STASIS_CPU_COUNT", "CPU_COUNT", NULL}},
+ {.flags = STASIS_ENVCTL_REQUIRED | STASIS_ENVCTL_REDACT, .name={"STASIS_GH_TOKEN", "GITHUB_TOKEN", NULL}},
+ {.flags = STASIS_ENVCTL_REDACT, .name = {"STASIS_JF_ACCESS_TOKEN", NULL}},
+ {.flags = STASIS_ENVCTL_PASSTHRU, .name = {"STASIS_JF_USER", NULL}},
+ {.flags = STASIS_ENVCTL_REDACT, .name = {"STASIS_JF_PASSWORD", NULL}},
+ {.flags = STASIS_ENVCTL_REDACT, .name = {"STASIS_JF_SSH_KEY_PATH", NULL}},
+ {.flags = STASIS_ENVCTL_REDACT, .name = {"STASIS_JF_SSH_PASSPHRASE", NULL}},
+ {.flags = STASIS_ENVCTL_REDACT, .name = {"STASIS_JF_CLIENT_CERT_CERT_PATH", NULL}},
+ {.flags = STASIS_ENVCTL_REDACT, .name = {"STASIS_JF_CLIENT_CERT_KEY_PATH", NULL}},
+ {.flags = STASIS_ENVCTL_REQUIRED, .name = {"STASIS_JF_REPO", NULL}},
+ {.flags = 0, .name = {NULL}},
+ }
};
void globals_free() {
diff --git a/src/ini.c b/src/ini.c
index d0757e6..6ce54ac 100644
--- a/src/ini.c
+++ b/src/ini.c
@@ -153,7 +153,7 @@ int ini_getval(struct INIFILE *ini, char *section_name, char *key, int type, uni
case INIVAL_TYPE_STR_ARRAY:
strcpy(tbufp, data->value);
*data->value = '\0';
- for (size_t i = 0; (token = strsep(&tbufp, "\n")) != NULL; i++) {
+ while ((token = strsep(&tbufp, "\n")) != NULL) {
lstrip(token);
strcat(data->value, token);
strcat(data->value, "\n");
diff --git a/src/junitxml.c b/src/junitxml.c
index c1bf1be..9c7e5b4 100644
--- a/src/junitxml.c
+++ b/src/junitxml.c
@@ -146,16 +146,16 @@ static struct StrList *attributes_to_strlist(xmlTextReaderPtr reader) {
static int read_xml_data(xmlTextReaderPtr reader, struct JUNIT_Testsuite **testsuite) {
const xmlChar *name;
- const xmlChar *value;
+ //const xmlChar *value;
name = xmlTextReaderConstName(reader);
if (!name) {
// name could not be converted to string
name = BAD_CAST "--";
}
- value = xmlTextReaderConstValue(reader);
+ //value = xmlTextReaderConstValue(reader);
const char *node_name = (char *) name;
- const char *node_value = (char *) value;
+ //const char *node_value = (char *) value;
struct StrList *attrs = attributes_to_strlist(reader);
if (attrs && strlist_count(attrs)) {
diff --git a/src/stasis_main.c b/src/stasis_main.c
index c550982..ce49829 100644
--- a/src/stasis_main.c
+++ b/src/stasis_main.c
@@ -9,6 +9,7 @@
#define OPT_NO_DOCKER 1001
#define OPT_NO_ARTIFACTORY 1002
#define OPT_NO_TESTING 1003
+#define OPT_OVERWRITE 1004
static struct option long_options[] = {
{"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'},
@@ -18,6 +19,7 @@ static struct option long_options[] = {
{"verbose", no_argument, 0, 'v'},
{"unbuffered", no_argument, 0, 'U'},
{"update-base", no_argument, 0, OPT_ALWAYS_UPDATE_BASE},
+ {"overwrite", no_argument, 0, OPT_OVERWRITE},
{"no-docker", no_argument, 0, OPT_NO_DOCKER},
{"no-artifactory", no_argument, 0, OPT_NO_ARTIFACTORY},
{"no-testing", no_argument, 0, OPT_NO_TESTING},
@@ -33,6 +35,7 @@ const char *long_options_help[] = {
"Increase output verbosity",
"Disable line buffering",
"Update conda installation prior to STASIS environment creation",
+ "Overwrite an existing release",
"Do not build docker images",
"Do not upload artifacts to Artifactory",
"Do not execute test scripts",
@@ -95,7 +98,32 @@ static void usage(char *progname) {
}
}
+static const char *has_envctl_key_(size_t i) {
+ for (size_t x = 0; globals.envctl[i].name[x] != NULL; x++) {
+ const char *name = globals.envctl[i].name[x];
+ const char *data = getenv(name);
+ if (data) {
+ return name;
+ }
+ }
+ return NULL;
+}
+
+static void check_system_env_requirements() {
+ msg(STASIS_MSG_L1, "Checking environment\n");
+ for (size_t i = 0; globals.envctl[i].name[0] != NULL; i++) {
+ unsigned int flags = globals.envctl[i].flags;
+ const char *key = has_envctl_key_(i);
+ if ((flags & STASIS_ENVCTL_REQUIRED) && !(key && strlen(getenv(key)))) {
+ if (!strcmp(key, "STASIS_JF_REPO") && !globals.enable_artifactory) {
+ continue;
+ }
+ msg(STASIS_MSG_L2 | STASIS_MSG_ERROR, "Environment variable '%s' must be configured.\n", globals.envctl[i].name[0]);
+ exit(1);
+ }
+ }
+}
static void check_system_requirements(struct Delivery *ctx) {
const char *tools_required[] = {
@@ -139,6 +167,11 @@ static void check_system_requirements(struct Delivery *ctx) {
}
}
+static void check_requirements(struct Delivery *ctx) {
+ check_system_requirements(ctx);
+ check_system_env_requirements();
+}
+
int main(int argc, char *argv[]) {
struct Delivery ctx;
struct Process proc = {
@@ -193,6 +226,9 @@ int main(int argc, char *argv[]) {
case 'v':
globals.verbose = true;
break;
+ case OPT_OVERWRITE:
+ globals.enable_overwrite = true;
+ break;
case OPT_NO_DOCKER:
globals.enable_docker = false;
user_disabled_docker = true;
@@ -260,6 +296,11 @@ int main(int argc, char *argv[]) {
tpl_register("workaround.tox_posargs", &globals.workaround.tox_posargs);
tpl_register("workaround.conda_reactivate", &globals.workaround.conda_reactivate);
+ // Expose function(s) to the template engine
+ // Prototypes can be found in template_func_proto.h
+ tpl_register_func("get_github_release_notes", &get_github_release_notes_tplfunc_entrypoint, 3, NULL);
+ tpl_register_func("get_github_release_notes_auto", &get_github_release_notes_auto_tplfunc_entrypoint, 1, &ctx);
+
// Set up PREFIX/etc directory information
// The user may manipulate the base directory path with STASIS_SYSCONFDIR
// environment variable
@@ -325,7 +366,7 @@ int main(int argc, char *argv[]) {
msg(STASIS_MSG_ERROR | STASIS_MSG_L2, "Failed to initialize delivery context\n");
exit(1);
}
- check_system_requirements(&ctx);
+ check_requirements(&ctx);
msg(STASIS_MSG_L2, "Configuring JFrog CLI\n");
if (delivery_init_artifactory(&ctx)) {
@@ -346,6 +387,12 @@ int main(int argc, char *argv[]) {
//delivery_runtime_show(&ctx);
}
+ // Safety gate: Avoid clobbering a delivery unless the user wants that behavior
+ if (delivery_exists(&ctx)) {
+ msg(STASIS_MSG_ERROR | STASIS_MSG_L1, "Refusing to overwrite delivery: %s\nUse --overwrite to enable release clobbering.\n", ctx.info.release_name);
+ exit(1);
+ }
+
msg(STASIS_MSG_L1, "Conda setup\n");
delivery_get_installer_url(&ctx, installer_url);
msg(STASIS_MSG_L2, "Downloading: %s\n", installer_url);
diff --git a/src/template.c b/src/template.c
index 819fe92..8e1357c 100644
--- a/src/template.c
+++ b/src/template.c
@@ -25,10 +25,14 @@ extern void tpl_reset() {
tpl_pool_func_used = 0;
}
-void tpl_register_func(char *key, struct tplfunc_frame *frame) {
- (void) key; // TODO: placeholder
- tpl_pool_func[tpl_pool_func_used] = calloc(1, sizeof(*tpl_pool_func[tpl_pool_func_used]));
- memcpy(tpl_pool_func[tpl_pool_func_used], frame, sizeof(*frame));
+void tpl_register_func(char *key, void *tplfunc_ptr, int argc, void *data_in) {
+ struct tplfunc_frame *frame = calloc(1, sizeof(*frame));
+ frame->key = strdup(key);
+ frame->argc = argc;
+ frame->func = tplfunc_ptr;
+ frame->data_in = data_in;
+
+ tpl_pool_func[tpl_pool_func_used] = frame;
tpl_pool_func_used++;
}
@@ -220,7 +224,7 @@ char *tpl_render(char *str) {
strcpy(func_name_temp, type_stop + 1);
char *param_begin = strchr(func_name_temp, '(');
if (!param_begin) {
- fprintf(stderr, "offset %zu: function name must be followed by a '('\n", off);
+ fprintf(stderr, "At position %zu in %s\nfunction name must be followed by a '('\n", off, key);
guard_free(output);
return NULL;
}
@@ -228,7 +232,7 @@ char *tpl_render(char *str) {
param_begin++;
char *param_end = strrchr(param_begin, ')');
if (!param_end) {
- fprintf(stderr, "offset %zu: function arguments must be closed with a ')'\n", off);
+ fprintf(stderr, "At position %zu in %s\nfunction arguments must be closed with a ')'\n", off, key);
guard_free(output);
return NULL;
}
@@ -239,8 +243,8 @@ char *tpl_render(char *str) {
for (params_count = 0; params[params_count] != NULL; params_count++);
struct tplfunc_frame *frame = tpl_getfunc(k);
- if (params_count > frame->argc) {
- fprintf(stderr, "offset %zu: Too many arguments for function: %s()\n", off, frame->key);
+ if (params_count > frame->argc || params_count < frame->argc) {
+ fprintf(stderr, "At position %zu in %s\nIncorrect number of arguments for function: %s (expected %d, got %d)\n", off, key, frame->key, frame->argc, params_count);
value = strdup("");
} else {
for (size_t p = 0; p < sizeof(frame->argv) / sizeof(*frame->argv) && params[p] != NULL; p++) {
@@ -248,10 +252,13 @@ char *tpl_render(char *str) {
strip(params[p]);
frame->argv[p].t_char_ptr = params[p];
}
- char func_result[100];
- char *fres = func_result;
- frame->func(frame, fres);
- value = strdup(fres);
+ char *func_result = NULL;
+ int func_status = 0;
+ if ((func_status = frame->func(frame, &func_result))) {
+ fprintf(stderr, "%s returned non-zero status: %d\n", frame->key, func_status);
+ }
+ value = strdup(func_result ? func_result : "");
+ guard_free(func_result);
}
GENERIC_ARRAY_FREE(params);
} else {
diff --git a/src/template_func_proto.c b/src/template_func_proto.c
new file mode 100644
index 0000000..140a5e0
--- /dev/null
+++ b/src/template_func_proto.c
@@ -0,0 +1,65 @@
+#include "template_func_proto.h"
+
+int get_github_release_notes_tplfunc_entrypoint(void *frame, void *data_out) {
+ int result;
+ char **output = (char **) data_out;
+ struct tplfunc_frame *f = (struct tplfunc_frame *) frame;
+ char *api_token = getenv("STASIS_GH_TOKEN");
+ if (!api_token) {
+ api_token = getenv("GITHUB_TOKEN");
+ }
+ result = get_github_release_notes(api_token ? api_token : "anonymous",
+ (const char *) f->argv[0].t_char_ptr,
+ (const char *) f->argv[1].t_char_ptr,
+ (const char *) f->argv[2].t_char_ptr,
+ output);
+ return result;
+}
+
+int get_github_release_notes_auto_tplfunc_entrypoint(void *frame, void *data_out) {
+ int result = 0;
+ char **output = (char **) data_out;
+ struct tplfunc_frame *f = (struct tplfunc_frame *) frame;
+ char *api_token = getenv("STASIS_GITHUB_TOKEN");
+ if (!api_token) {
+ api_token = getenv("GITHUB_TOKEN");
+ }
+
+ const struct Delivery *ctx = (struct Delivery *) f->data_in;
+ struct StrList *notes_list = strlist_init();
+ for (size_t i = 0; i < sizeof(ctx->tests) / sizeof(*ctx->tests); i++) {
+ // Get test context
+ const struct Test *test = &ctx->tests[i];
+ if (test->name && test->version && test->repository) {
+ char *repository = strdup(test->repository);
+ char *match = strstr(repository, "spacetelescope/");
+ // Cull repository URL
+ if (match) {
+ replace_text(repository, "https://github.com/", "", 0);
+ if (endswith(repository, ".git")) {
+ replace_text(repository, ".git", "", 0);
+ }
+ // Record release notes for version relative to HEAD
+ // Using HEAD, GitHub returns the previous tag
+ char *note = NULL;
+ char h1_title[NAME_MAX] = {0};
+ sprintf(h1_title, "# %s", test->name);
+ strlist_append(&notes_list, h1_title);
+ result += get_github_release_notes(api_token ? api_token : "anonymous",
+ repository,
+ test->version,
+ "HEAD",
+ &note);
+ if (note) {
+ strlist_append(&notes_list, note);
+ guard_free(note);
+ }
+ }
+ }
+ }
+ // Return all notes as a single string
+ if (strlist_count(notes_list)) {
+ *output = join(notes_list->data, "\n\n");
+ }
+ return result;
+}