diff --git a/.dir-locals.el b/.dir-locals.el index 809ecad..d3b88ef 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -7,6 +7,6 @@ (eglot-ensure) (add-hook 'before-save-hook 'eglot-format nil t))))) (nil . ((omg-pull-target-repo . "jiacai2050/oh-my-github") - (omg-pull-target-branch . "master") + (omg-pull-target-branch . "main") (omg-pull-username . "jiacai2050") (omg-pull-draft . "false")))) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 461ebdb..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,5 +0,0 @@ -## Rationale - -## Detailed Changes - -## Test Plan diff --git a/build.zig b/build.zig index c09733c..ab2fd39 100644 --- a/build.zig +++ b/build.zig @@ -10,6 +10,7 @@ pub fn build(b: *std.Build) !void { const quick = b.option(bool, "quick", "Enable quick mode"); const core_lib = b.addStaticLibrary(.{ .name = "omg-core", + .root_source_file = .{ .path = "core/omg.zig" }, .target = target, .optimize = optimize, }); @@ -33,6 +34,7 @@ pub fn build(b: *std.Build) !void { try cflags.append("-DOMG_TEST"); } } + core_lib.addIncludePath("core"); core_lib.addCSourceFile("./core/omg.c", cflags.items); core_lib.linkSystemLibrary("sqlite3"); core_lib.linkSystemLibrary("libcurl"); diff --git a/core/omg.c b/core/omg.c index a239a09..b3555d6 100644 --- a/core/omg.c +++ b/core/omg.c @@ -54,17 +54,17 @@ static void free_response(response *resp) { } } -/* static void free_curl_slist(struct curl_slist **lst) { */ -/* if (*lst) { */ -/* #ifdef VERBOSE */ -/* printf("free curl list, body is %s\n", (*lst)->data); */ -/* #endif */ -/* curl_slist_free_all(*lst); */ -/* } */ -/* } */ - -/* #define auto_curl_slist \ */ -/* struct curl_slist __attribute__((cleanup(free_curl_slist))) */ +// static void free_curl_slist(struct curl_slist **lst) { +// if (*lst) { +// #ifdef VERBOSE +// printf("free curl list, body is %s\n", (*lst)->data); +// #endif +// curl_slist_free_all(*lst); +// } +// } + +// #define auto_curl_slist \ +// struct curl_slist __attribute__((cleanup(free_curl_slist))) static void free_curl_handler(CURL **curl) { if (*curl) { @@ -145,7 +145,7 @@ bool is_ok(omg_error err) { return err.code == OMG_CODE_OK; } static const omg_error NO_ERROR = {.code = OMG_CODE_OK, .message = {}}; -static omg_error new_error(int code, const char *msg) { +omg_error new_error(int code, const char *msg) { size_t msg_size = strlen(msg); if (msg_size == 0) { return NO_ERROR; @@ -278,6 +278,8 @@ omg_error omg_setup_context(const char *path, const char *github_token, return NO_ERROR; } +CURL *omg__curl_handler(omg_context ctx) { return ctx->api_curl; } + static omg_error omg_request(omg_context ctx, const char *method, const char *url, json_t *payload, json_t **out) { CURL *curl = ctx->api_curl; @@ -345,8 +347,7 @@ omg_error omg_download(omg_context ctx, const char *url, const char *filename) { FILE *file = fopen(filename, "wb"); if (!file) { - return (omg_error){.code = OMG_CODE_INTERNAL, - .message = "open file failed"}; + return new_error(OMG_CODE_INTERNAL, "open db file failed"); } curl_easy_setopt(curl, CURLOPT_WRITEDATA, file); @@ -360,8 +361,7 @@ omg_error omg_download(omg_context ctx, const char *url, const char *filename) { curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); if (response_code >= 400) { fprintf(stderr, "Download %s failed with %ld", filename, response_code); - return (omg_error){.code = OMG_CODE_CURL, - .message = "download file failed"}; + return new_error(OMG_CODE_CURL, "download file failed"); } return NO_ERROR; @@ -1529,32 +1529,39 @@ omg_error omg_create_discusstion(omg_context ctx, const char *repo_id, json_auto_t *request = json_object(); json_object_set_new(request, "query", json_string(mutation)); - // { - // "data": { - // "createDiscussion": { - // "clientMutationId": null, - // "discussion": { - // "id": "D_kwDOAScDVc4AUngI", - // "title": "The title", - // "body": "The body", - // "createdAt": "2023-07-15T11:03:47Z", - // "url": "https://github.com/jiacai2050/blog/discussions/14" - // } - // } - // } - // } json_auto_t *response = NULL; omg_error err = omg_request(ctx, POST_METHOD, GRAPHQL_ROOT, request, &response); if (!is_ok(err)) { return err; } - json_t *discussion = json_object_get( - json_object_get(json_object_get(response, "data"), "createDiscussion"), - "discussion"); + json_t *create = + json_object_get(json_object_get(response, "data"), "createDiscussion"); + if (json_is_null(create)) { + json_t *errors = json_object_get(response, "errors"); + if (json_is_null(errors)) { + return (omg_error){.code = OMG_CODE_CURL, .message = "Unknown error!"}; + } + + return new_error(OMG_CODE_CURL, json_string_value(json_object_get( + json_array_get(errors, 0), "message"))); + } + json_t *discussion = json_object_get(create, "discussion"); *out = (omg_discussion){.id = dup_json_string(discussion, "id"), .url = dup_json_string(discussion, "url")}; return NO_ERROR; } + +void omg_free_discussion(omg_discussion *discussion) { + if (discussion != NULL) { + if (discussion->url) { + free(discussion->url); + } + + if (discussion->id) { + free(discussion->id); + } + } +} diff --git a/core/omg.h b/core/omg.h index c4cd10f..e4d16a1 100644 --- a/core/omg.h +++ b/core/omg.h @@ -1,5 +1,6 @@ #ifndef OMG_H #define OMG_H +#include #include #include #include @@ -23,9 +24,10 @@ typedef struct omg_error { void print_error(omg_error err); bool is_ok(omg_error err); +omg_error new_error(int code, const char *msg); -/* Opaque pointer representing omg core concept: context - All omg-related functions require this argument! */ +// Opaque pointer representing omg core concept. +// All omg-related functions require this argument! typedef struct omg_context *omg_context; omg_error omg_setup_context(const char *path, const char *github_token, @@ -34,6 +36,9 @@ omg_error omg_setup_context(const char *path, const char *github_token, void omg_free_context(omg_context *ctx); #define omg_auto_context omg_context __attribute__((cleanup(omg_free_context))) +// Internal usage. +CURL *omg__curl_handler(omg_context); + typedef struct { int id; const char *full_name; @@ -253,10 +258,30 @@ typedef struct { char *url; } omg_discussion; +void omg_free_discussion(omg_discussion *); + +#define omg_auto_discussion \ + omg_discussion __attribute__((cleanup(omg_free_discussion))) + omg_error omg_create_discusstion(omg_context ctx, const char *repo_id, const char *category_id, const char *title, const char *body, omg_discussion *out); +typedef struct { + char *id; + char *name; +} omg_discussion_category; + +typedef struct { + char *id; + omg_discussion_category *categories; + size_t len; +} omg_repo_discussion_category; + +omg_error omg_query_repo_discussion_category(omg_context, const char *owner, + const char *name, + omg_repo_discussion_category *); + // Utils omg_error omg_download(omg_context ctx, const char *url, const char *filename); diff --git a/core/omg.zig b/core/omg.zig new file mode 100644 index 0000000..ea395b1 --- /dev/null +++ b/core/omg.zig @@ -0,0 +1,157 @@ +const std = @import("std"); +const c = @cImport({ + @cInclude("omg.h"); +}); + +const allocator = std.heap.c_allocator; +const ResponseBuffer = std.ArrayList(u8); +const GRAPHQL_API = "https://api.github.com/graphql"; + +// Copy from https://ziglang.org/learn/samples/#using-curl-from-zig +fn writeToArrayListCallback(data: *anyopaque, size: c_uint, nmemb: c_uint, user_data: *anyopaque) callconv(.C) c_uint { + var buffer: *ResponseBuffer = @alignCast(@ptrCast(user_data)); + var typed_data: [*]u8 = @ptrCast(data); + buffer.appendSlice(typed_data[0 .. nmemb * size]) catch return 0; + return nmemb * size; +} + +fn request(ctx: ?*c.struct_omg_context, url: [:0]const u8, method: [:0]const u8, payload: ?[:0]const u8) !ResponseBuffer { + const handle: ?*c.CURL = c.omg__curl_handler(ctx); + if (c.curl_easy_setopt(handle, c.CURLOPT_URL, url.ptr) != c.CURLE_OK) + return error.CouldNotSetURL; + if (c.curl_easy_setopt(handle, c.CURLOPT_CUSTOMREQUEST, method.ptr) != c.CURLE_OK) + return error.CouldNotSetMethod; + + if (payload) |p| { + if (c.curl_easy_setopt(handle, c.CURLOPT_POSTFIELDS, p.ptr) != c.CURLE_OK) { + return error.CouldNotSetPayload; + } + } + var response_buffer = ResponseBuffer.init(allocator); + + // _ = c.curl_easy_setopt(handle, c.CURLOPT_VERBOSE, @as(c_long, 10)); + + if (c.curl_easy_setopt(handle, c.CURLOPT_WRITEFUNCTION, writeToArrayListCallback) != c.CURLE_OK) + return error.CouldNotSetWriteCallback; + if (c.curl_easy_setopt(handle, c.CURLOPT_WRITEDATA, &response_buffer) != c.CURLE_OK) + return error.CouldNotSetWriteCallback; + + // perform + if (c.curl_easy_perform(handle) != c.CURLE_OK) + return error.FailedToPerformRequest; + + return response_buffer; +} + +fn query_inner(ctx: ?*c.struct_omg_context, owner: []const u8, name: []const u8, diag: *c.omg_error) !c.omg_repo_discussion_category { + var q = try std.fmt.allocPrintZ(allocator, + \\ query {{ + \\ repository(name: "{s}", owner: "{s}") {{ + \\ id + \\ url + \\ discussionCategories(first:100) {{ + \\ edges {{ + \\ node {{ + \\ id + \\ name + \\ slug + \\ }} + \\ }} + \\ }} + \\ }} + \\ }} + , .{ name, owner }); + defer allocator.free(q); + + var payload = std.ArrayList(u8).init(allocator); + defer payload.deinit(); + + try std.json.stringify(.{ .query = q }, .{}, payload.writer()); + try payload.append(0); + + const resp = try request( + ctx, + GRAPHQL_API, + "POST", + payload.items[0 .. payload.items.len - 1 :0], + ); + + const CategoryList = struct { + data: struct { + repository: ?struct { + id: []const u8, + discussionCategories: struct { + edges: []struct { + node: struct { + id: []const u8, + name: []const u8, + slug: []const u8, + }, + }, + }, + } = null, + }, + errors: ?[]struct { + message: []const u8, + } = null, + }; + + const json = try std.json.parseFromSlice(CategoryList, allocator, resp.items, .{ + .ignore_unknown_fields = true, + }); + defer json.deinit(); + + try std.json.stringify(json.value, .{ + .whitespace = .{ + .indent = .{ .space = 1 }, + .separator = false, + }, + }, std.io.getStdOut().writer()); + + if (json.value.errors) |e| { + const msg = try std.fmt.allocPrintZ(allocator, "{s}", .{e[0].message}); + defer allocator.free(msg); + + diag.* = c.new_error(c.OMG_CODE_GITHUB, msg); + return error.GitHubError; + } + + if (json.value.data.repository) |repo| { + const repo_id = try std.fmt.allocPrintZ(allocator, "{s}", .{repo.id}); + const len = repo.discussionCategories.edges.len; + var list = try allocator.alloc(c.omg_discussion_category, len); + for (repo.discussionCategories.edges, 0..) |edge, idx| { + list[idx] = c.omg_discussion_category{ + .id = try std.fmt.allocPrintZ(allocator, "{s}", .{edge.node.id}), + .name = try std.fmt.allocPrintZ(allocator, "{s}", .{edge.node.name}), + }; + } + + return c.omg_repo_discussion_category{ + .id = repo_id.ptr, + .categories = list.ptr, + .len = len, + }; + } else { + return error.EmptyCategory; + } +} + +export fn omg_query_repo_discussion_category( + ctx: c.omg_context, + owner: [*c]const u8, + name: [*c]const u8, + out: *c.omg_repo_discussion_category, +) c.omg_error { + var diag = c.omg_error{ .code = c.OMG_CODE_OK, .message = .{} }; + const r = query_inner(ctx, std.mem.span(owner), std.mem.span(name), &diag) catch |e| { + if (c.is_ok(diag)) { + return c.new_error(c.OMG_CODE_INTERNAL, @errorName(e)); + } + + return diag; + }; + + out.* = r; + return c.omg_error{ .code = c.OMG_CODE_OK, .message = .{} }; +} diff --git a/emacs/emacs.c b/emacs/emacs.c index fa2868e..3c78136 100644 --- a/emacs/emacs.c +++ b/emacs/emacs.c @@ -702,6 +702,28 @@ emacs_value omg_dyn_create_pull(emacs_env *env, ptrdiff_t nargs, lisp_symbol(env, "deletions"), lisp_integer(env, create_ret.deletions)); } +emacs_value omg_dyn_create_discussion(emacs_env *env, ptrdiff_t nargs, + emacs_value *args, void *data) { + ENSURE_SETUP(env); + omg_auto_char repo_id = get_string(env, args[0]); + omg_auto_char category_id = get_string(env, args[1]); + omg_auto_char title = get_string(env, args[2]); + omg_auto_char text = get_string(env, args[3]); + ENSURE_NONLOCAL_EXIT(env); + + omg_auto_discussion create_ret = {}; + omg_error err = omg_create_discusstion(ctx, repo_id, category_id, title, text, + &create_ret); + + if (!is_ok(err)) { + return lisp_funcall(env, "error", lisp_string(env, (char *)err.message)); + } + + return lisp_funcall(env, "list", lisp_symbol(env, "id"), + lisp_string(env, create_ret.id), lisp_symbol(env, "url"), + lisp_string(env, create_ret.url), ); +} + emacs_value omg_dyn_setup(emacs_env *env, ptrdiff_t nargs, emacs_value *args, void *data) { if (ctx) { @@ -823,7 +845,11 @@ int emacs_module_init(runtime ert) { lisp_funcall(env, "fset", lisp_symbol(env, "omg-dyn-create-pull"), env->make_function(env, 6, 6, omg_dyn_create_pull, - "Create GitHub Pull Request", NULL)); + "Create GitHub pull request", NULL)); + + lisp_funcall(env, "fset", lisp_symbol(env, "omg-dyn-create-discussion"), + env->make_function(env, 4, 4, omg_dyn_create_discussion, + "Create GitHub discussion", NULL)); lisp_funcall(env, "provide", lisp_symbol(env, FEATURE_NAME)); diff --git a/emacs/omg-discussion.el b/emacs/omg-discussion.el new file mode 100644 index 0000000..d5a1317 --- /dev/null +++ b/emacs/omg-discussion.el @@ -0,0 +1,79 @@ +;;; -*- lexical-binding: t -*- + +(require 'org) +(require 'ox-md) +(require 'omg-dyn) + +(defcustom omg-discussion-open-in-browser t + "If non-nil open discussion link in browser via `browse-url-default-browser' after created." + :group 'omg + :type 'boolean) + +(defvar-local omg-discussion-repo-id nil) +(defvar-local omg-discussion-category-id nil) + +(defvar omg-discussion--buf-basename "*omg-discussion create(%s)*") +(defvar omg-discussion--repo-root nil) +(defconst omg-discussion--header + "Edit, then submit with `\\[omg-discussion-submit]', or cancel with `\\[omg-discussion-cancel]'") + +(defun omg-discussion-submit () + (interactive) + (setq-local org-export-options-alist '((:title "TITLE" nil nil t) + (:repo-id "REPO-ID" nil nil t) + (:category-id "CATEGORY-ID" nil nil t))) + (let* ((metadata (org-export-get-environment)) + (title (plist-get metadata :title)) + (repo-id (plist-get metadata :repo-id)) + (category-id (plist-get metadata :category-id)) + (body (with-current-buffer (org-export-to-buffer 'md "*OMG-DISCUSSION export*") + (let ((body (buffer-substring-no-properties (point-min) (point-max)))) + (kill-buffer) + body))) + (ret (omg-dyn-create-discussion repo-id category-id title body)) + (link (plist-get ret 'url))) + (message "Discussion created: %s" ret) + (when omg-discussion-open-in-browser + (browse-url-default-browser link)))) + +(defun omg-discussion-cancel () + (interactive) + (when (y-or-n-p "Cancel this discussion, are you sure?") + (kill-buffer))) + +(defvar omg-discussion-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map org-mode-map) + (define-key map (kbd "C-c C-c") 'omg-discussion-submit) + (define-key map (kbd "C-c C-k") 'omg-discussion-cancel) + map) + "Local keymap for omg-discussion-create-discussion-mode buffers.") + +(define-derived-mode omg-discussion-mode org-mode "omg-discussion" "Create discussion for GitHub repository") + +;;;###autoload +(defun omg-discussion-create () + (interactive) + (let* ((meta (concat "#+TITLE:" + "\n#+REPO-ID:" + omg-discussion-repo-id + "\n#+CATEGORY-ID:" + omg-discussion-category-id + "\n"))) + (with-current-buffer (get-buffer-create (format omg-discussion--buf-basename + omg-discussion-repo-id)) + (beginning-of-buffer) + (erase-buffer) + (insert meta) + (omg-discussion-mode) + (setq header-line-format (substitute-command-keys omg-discussion--header)) + (beginning-of-buffer) + (switch-to-buffer (current-buffer))))) + +(provide 'omg-discussion) + +;; Local Variables: +;; coding: utf-8 +;; End: + +;;; omg-discussion.el ends here diff --git a/emacs/omg.el b/emacs/omg.el index 7ac5d80..6bf3b39 100644 --- a/emacs/omg.el +++ b/emacs/omg.el @@ -38,6 +38,7 @@ (require 'seq) (require 'omg-core) (require 'omg-commit) +(require 'omg-discussion) (require 'omg-gist) (require 'omg-pull) (require 'omg-release) diff --git a/tests/discussion.zig b/tests/discussion.zig index 38ec8bd..1b47ed0 100644 --- a/tests/discussion.zig +++ b/tests/discussion.zig @@ -1,17 +1,35 @@ const std = @import("std"); const util = @import("util.zig"); -const testing = std.testing; -const time = std.time; - const c = @cImport({ @cInclude("omg.h"); }); +const testing = std.testing; +const time = std.time; + pub fn main() !void { const ctx = try util.init_ctx(); - const repo_id = "R_kgDOAScDVQ"; - const category_id = "DIC_kwDOAScDVc4CSP-y"; + // https://github.com/xigua2023/test-github-api/discussions + // try create(ctx); + try query(ctx); +} + +fn query(ctx: c.omg_context) !void { + var out = c.omg_repo_discussion_category{ + .id = null, + .categories = null, + .len = 0, + }; + try util.check_error(c.omg_query_repo_discussion_category(ctx, "xigua2023", "test-github-api", &out)); + defer { + // TODO: free out + } +} + +fn create(ctx: c.omg_context) !void { + const repo_id = "R_kgDOJ8AzuQ"; + const category_id = "DIC_kwDOJ8Azuc4CX6mT"; var buf = std.mem.zeroes([40]u8); const title = try std.fmt.bufPrintZ(&buf, "Awesome Title-{d}", .{time.milliTimestamp()}); const body = "## test from omg\n Succeed!"; @@ -23,7 +41,7 @@ pub fn main() !void { } const url = std.mem.span(out.url); - try testing.expect(std.mem.indexOf(u8, url, "jiacai2050") != null); + try testing.expect(std.mem.indexOf(u8, url, "xigua") != null); try testing.expect(out.id != null); // std.debug.print("{s}-{s}\n", .{ out.url, out.id }); } diff --git a/tests/util.zig b/tests/util.zig index 4babb72..237bb46 100644 --- a/tests/util.zig +++ b/tests/util.zig @@ -4,7 +4,7 @@ const c = @cImport({ @cInclude("omg.h"); }); -pub fn init_ctx() !?*c.struct_omg_context { +pub fn init_ctx() !c.omg_context { const db_path = std.c.getenv("DB_PATH").?; const token = std.c.getenv("GITHUB_TOKEN").?; var ctx: ?*c.struct_omg_context = null;