├── .Rbuildignore ├── .gitignore ├── DESCRIPTION ├── LICENSE ├── LICENSE.md ├── NAMESPACE ├── NEWS.md ├── R ├── assertions.R ├── display_issue_search_results.R ├── document_manipulation.R ├── documents.R ├── draft_issue.R ├── extract_comments.R ├── extract_issues.R ├── get_issue_thread.R ├── get_maybe_cached_issue.R ├── gh_thread.R ├── github_issue.R ├── issue_thread.R ├── make_issue_info.R ├── navigate_issue_search_results.R ├── parse_issue_content.R ├── register_reprex_knitr_engine.R ├── render_issue_body.R ├── render_issue_body_footer.R ├── render_issue_search_front_matter.R ├── render_issue_search_results.R ├── render_issue_summaries.R ├── resolve_package_repo.R ├── results_cache.R ├── rmdgh-package.R ├── save_issue.R ├── search.R └── utils.R ├── README.md ├── discovery ├── Rmd.Rmd └── gh.Rmd ├── inst └── rmarkdown │ └── templates │ └── github_issue │ ├── skeleton │ └── skeleton.Rmd │ └── template.yaml ├── man ├── draft_issue.Rd ├── get_gh_user.Rd ├── gh_thread.Rd ├── github_issue.Rd ├── issues.Rd ├── rmdgh-package.Rd └── save_issue.Rd └── tests ├── testthat.R └── testthat ├── test-forward_match_shortcode.R └── test-utils.R /.Rbuildignore: -------------------------------------------------------------------------------- 1 | ^LICENSE\.md$ 2 | ^\./discovery$ 3 | .vscode 4 | ^\./issues$ 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Rhistory 2 | .vscode 3 | issues -------------------------------------------------------------------------------- /DESCRIPTION: -------------------------------------------------------------------------------- 1 | Package: rmdgh 2 | Title: R to GitHub productivity via Rmarkdown 3 | Version: 0.3.2 4 | Authors@R: 5 | person("Miles", "McBain", , "miles.mcbain@gmail.com", role = c("aut", "cre"), 6 | comment = c(ORCID = "0000-0003-2865-2548")) 7 | Description: Browse and interact with GitHub via an Rmarkdown document interface. 8 | License: MIT + file LICENSE 9 | Encoding: UTF-8 10 | Roxygen: list(markdown = TRUE) 11 | RoxygenNote: 7.2.1 12 | Imports: 13 | assertthat, 14 | fs, 15 | gert, 16 | reprex, 17 | rmarkdown (>= 2.15), 18 | xfun, 19 | yaml, 20 | backports, 21 | magrittr, 22 | atcursor (>= 0.0.2), 23 | clipr, 24 | curl, 25 | gh, 26 | glue, 27 | jsonlite, 28 | knitr, 29 | lubridate, 30 | prettyunits, 31 | rstudioapi, 32 | rvest, 33 | snakecase, 34 | tidyr, 35 | uuid, 36 | withr, 37 | utils, 38 | stats, 39 | usethis 40 | Suggests: 41 | testthat (>= 3.0.0) 42 | Config/testthat/edition: 3 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | YEAR: 2022 2 | COPYRIGHT HOLDER: rmdgh authors 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2022 rmdgh authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NAMESPACE: -------------------------------------------------------------------------------- 1 | # Generated by roxygen2: do not edit by hand 2 | 3 | S3method(extract_comments,default) 4 | S3method(extract_comments,gh_response) 5 | S3method(extract_comments,issue) 6 | S3method(extract_issues,default) 7 | S3method(extract_issues,gh_response) 8 | S3method(make_issue_info,hashref) 9 | S3method(make_issue_info,shortcode) 10 | export(draft_issue) 11 | export(get_gh_user) 12 | export(get_repo_remote) 13 | export(gh_for_me) 14 | export(gh_thread) 15 | export(github_issue) 16 | export(issue_search_results_backward) 17 | export(issue_search_results_expand) 18 | export(issue_search_results_forward) 19 | export(issues) 20 | export(issues_for_me) 21 | export(issues_with_me) 22 | export(jump_to_issue_thread) 23 | export(jump_to_issue_webpage) 24 | export(my_issues) 25 | export(my_prs) 26 | export(prs_for_me) 27 | export(prs_with_me) 28 | export(refresh_issue_thread) 29 | export(repo_issues) 30 | export(repo_prs) 31 | export(save_issue) 32 | importFrom(magrittr,"%>%") 33 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | # 0.3.2 2 | 3 | - Breaking change: flipped default for `wrap` in `github_issue` back to `"preserve"`. This works better after all. 4 | 5 | # 0.3.1 6 | 7 | - Image outputs now use the same strategy as `{reprex}` by default: `knitr::imgur_upload`. Thanks @rmflight. 8 | 9 | # 0.3.0 10 | 11 | - Depend on `{rmarkdown}` >= 2.15, thanks @rmflight 12 | - Breaking change: `github_issue()` wrap argument now defaults to `"auto"`, was `"preserve"`. 13 | - Breaking change: `draft_issue` now accepts a `path` argument that has a configurable default in option `rmd_gh_issue_draft_path`. This replaces the `tempdir` argument. 14 | - Generated issue thread Rmd document names now preserve case. 15 | - Added `save_issue()` to save issues in a configurable folder which is automatically created and added to `.Rbuildignore` if it does not exist. The default is `./issues`. Thanks @Robinlovelace -------------------------------------------------------------------------------- /R/assertions.R: -------------------------------------------------------------------------------- 1 | assert_github_exists <- function(repo, issue = NULL) { 2 | gh_issue <- glue::glue("/issues/{issue}") 3 | gh_repo <- glue::glue("/repos/{repo}") 4 | query <- paste0(gh_repo, gh_issue, collapse = "") 5 | tryCatch( 6 | gh::gh( 7 | query 8 | ), 9 | error = function(e) { 10 | stop("could not find repository on GitHub: ", repo, ".\n{gh} said:\n", e$message) 11 | } 12 | ) 13 | invisible(TRUE) 14 | } 15 | 16 | assert_CRAN_page_exists <- function(repo) { 17 | # I tried to make this a HEAD request but the CRAN server doesn't seem to respond to 18 | # HEAD correctly. 19 | res <- curl::curl_fetch_memory(cran_url(repo)) 20 | 21 | if (res$status_code != 200) stop(repo, " is not installed locally, and could not be located on CRAN") 22 | } 23 | 24 | assert_is_rmd <- function(document_context) { 25 | document_extension <- fs::path_ext(document_context$path) 26 | assertthat::assert_that(document_extension %in% c("Rmd", "Qmd")) 27 | } 28 | 29 | assert_github_issue <- function(issue_yaml) { 30 | assertthat::assert_that( 31 | assertthat::has_name(issue_yaml, "output") 32 | ) 33 | assertthat::assert_that( 34 | assertthat::has_name(issue_yaml$output, "rmdgh::github_issue") 35 | ) 36 | } -------------------------------------------------------------------------------- /R/display_issue_search_results.R: -------------------------------------------------------------------------------- 1 | display_issue_search_results <- function(result) { 2 | document_name <- snakecase::to_snake_case( 3 | paste( 4 | result$query_description, 5 | collapse = " " 6 | ) 7 | ) 8 | 9 | create_temp_document( 10 | document_name, 11 | render_issue_search_front_matter(result), 12 | render_issue_search_results(result$issues) 13 | ) |> 14 | rstudioapi::navigateToFile() 15 | 16 | } 17 | -------------------------------------------------------------------------------- /R/document_manipulation.R: -------------------------------------------------------------------------------- 1 | insert_text_below_cursor_line <- function(text) { 2 | cursor_selection <- rstudioapi::primary_selection(rstudioapi::getSourceEditorContext()) 3 | 4 | cursor_position <- cursor_selection$range$start 5 | 6 | rstudioapi::insertText( 7 | location = rstudioapi::document_position(row = cursor_position["row"] + 1, column = 0), 8 | text = paste0("\n", text, "\n") 9 | ) 10 | } 11 | 12 | -------------------------------------------------------------------------------- /R/documents.R: -------------------------------------------------------------------------------- 1 | create_temp_document <- function( 2 | document_title, 3 | document_front_matter, 4 | document_body 5 | ) { 6 | document_path <- file.path(get_pkg_user_dir(), md_document(document_title)) 7 | document_content <- 8 | paste0( 9 | document_front_matter, 10 | "\n", 11 | document_body 12 | ) 13 | writeLines( 14 | document_content, 15 | document_path 16 | ) 17 | document_path 18 | } 19 | 20 | clean_up_temp_doucments <- function() { 21 | 22 | } 23 | 24 | get_pkg_user_dir <- function() { 25 | pkg_user_dir <- tools::R_user_dir("rmdgh") 26 | if (!dir.exists(pkg_user_dir)) { 27 | dir.create(pkg_user_dir, recursive = TRUE) 28 | } 29 | pkg_user_dir 30 | } 31 | 32 | md_document <- function(document_tile) { 33 | paste0(document_tile, ".Rmd") 34 | # In future we'll need to check if user is rolling Qmd or Rmd. 35 | } 36 | 37 | .onLoad <- function(libname, pkgname) { 38 | backports::import(pkgname, "dir.exists") 39 | backports::import(pkgname, "R_user_dir", force = TRUE) 40 | 41 | # clean up 42 | pkg_user_dir <- get_pkg_user_dir() 43 | file_info_df <- fs::file_info(list.files(pkg_user_dir, full.names = TRUE)) 44 | # Remove all files from previous days 45 | old_files <- as.numeric(Sys.Date() - as.Date(file_info_df$birth_time, tz = Sys.timezone())) > 0 46 | unlink(file_info_df[old_files, ]$path) 47 | 48 | # register reprex engine 49 | register_reprex_knitr_engine() 50 | } 51 | -------------------------------------------------------------------------------- /R/draft_issue.R: -------------------------------------------------------------------------------- 1 | #' Create a GitHub issue from Rmarkdown 2 | #' 3 | #' An Rmarkdown document is created an opened that can add or comment on 4 | #' issues/PRs when rendered with `rmarkdown::render()`, the 'knit' button in 5 | #' RStudio, or the 'Knit Rmd' command in VSCode. 6 | #' 7 | #' By default issues are created in a temporary dir, but this can be overidden 8 | #' and they will be created in the current directory. 9 | #' 10 | #' @param filename the Rmarkdown file name to contain your issue 11 | #' @param path the path to create the rmd issue in. Defaults to 12 | #' `tools::R_user_dir("rmdgh")`. Issues created in the default directory will be 13 | #' automatically cleaned up. Default can be changed with option `rmd_gh_issue_draft_path`. 14 | #' @param overwrite whether or not to overwrite an existing issue with the same filename (defaults to TRUE). 15 | #' @export 16 | draft_issue <- function( 17 | filename = "issue.Rmd", 18 | path = getOption("rmdgh_issue_draft_path", get_pkg_user_dir()), 19 | overwrite = TRUE 20 | ) { 21 | file_path <- file.path(path, filename) 22 | 23 | if (file.exists(file_path) && overwrite) { 24 | unlink(file_path) 25 | } 26 | 27 | rmarkdown::draft( 28 | file = file_path, 29 | template = "github_issue", 30 | package = "rmdgh", 31 | create_dir = FALSE, 32 | edit = FALSE 33 | ) 34 | 35 | default_repo <- get_repo_remote() 36 | if (!is.null(default_repo)) { 37 | swap_repo_yaml(file_path, default_repo) 38 | } 39 | 40 | rstudioapi::navigateToFile(file_path) 41 | invisible(NULL) 42 | } 43 | 44 | 45 | swap_repo_yaml <- function(file_path, default_repo) { 46 | document_yaml <- rmarkdown::yaml_front_matter(file_path) 47 | yaml_length <- length(strsplit(yaml::as.yaml(document_yaml), "\n")[[1]]) + 2 # + 2 for "---" 48 | document_yaml$output$`rmdgh::github_issue`$repo <- default_repo 49 | document_lines <- xfun::read_utf8(file_path) 50 | new_document_lines <- c( 51 | paste0( 52 | "---\n", 53 | yaml::as.yaml(document_yaml), 54 | "---" 55 | ), 56 | document_lines[-seq(yaml_length)] 57 | ) 58 | unlink(file_path) 59 | xfun::write_utf8(new_document_lines, file_path) 60 | } 61 | -------------------------------------------------------------------------------- /R/extract_comments.R: -------------------------------------------------------------------------------- 1 | extract_comments <- function(result) UseMethod("extract_comments", result) 2 | 3 | #' @export 4 | extract_comments.default <- function(result) { 5 | stop( 6 | "Can't extract comments from object with classes: ", 7 | paste(class(result), collapse = ", ") 8 | ) 9 | } 10 | 11 | #' @export 12 | extract_comments.gh_response <- function(result) { 13 | 14 | lapply(result, function(comment) { 15 | list( 16 | author = comment$user$login, 17 | created_at = comment$created_at, 18 | body = comment$body 19 | ) 20 | }) 21 | 22 | } 23 | 24 | #' @export 25 | extract_comments.issue <- function(issue) { 26 | list( 27 | author = issue$author, 28 | created_at = issue$created_at, 29 | body = issue$body 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /R/extract_issues.R: -------------------------------------------------------------------------------- 1 | extract_issues <- function(result, ...) UseMethod("extract_issues", result) 2 | 3 | #' @export 4 | extract_issues.default <- function(result) { 5 | stop( 6 | "Can't extract issues from object with classes: ", 7 | paste(class(result), collapse = ", ") 8 | ) 9 | } 10 | 11 | #' @author Miles McBain 12 | #' @export 13 | extract_issues.gh_response <- function(result) { 14 | 15 | if ("items" %in% names(result)) { 16 | lapply(result$items, function(item) { 17 | make_issue(item) 18 | }) 19 | } else if ( 20 | all(c( 21 | "html_url", 22 | "number", 23 | "title", 24 | "body", 25 | "labels" 26 | ) %in% names(result)) 27 | ) { 28 | make_issue(result) 29 | } else { 30 | stop( 31 | "Can't extract issue(s) from {gh} API result, unknown type of result." 32 | ) 33 | } 34 | 35 | } 36 | 37 | extract_labels_gh <- function(item_labels) { 38 | lapply(item_labels, function(label) label$name) %>% 39 | unlist() 40 | } 41 | 42 | make_issue <- function(issue) { 43 | structure( 44 | list( 45 | number = issue$number, 46 | title = issue$title, 47 | body = issue$body %||% "", 48 | repo = get_repo_from_url(issue$html_url), 49 | api_url = issue$url, 50 | html_url = issue$html_url, 51 | labels = extract_labels_gh(issue$labels), 52 | author = issue$user$login, 53 | created_at = issue$created_at, 54 | type = if ("pull_request" %in% names(issue)) "pr" else "issue" 55 | ), 56 | class = "issue" 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /R/get_issue_thread.R: -------------------------------------------------------------------------------- 1 | get_issue_thread <- function(matching_issue) { 2 | 3 | result <- gh::gh( 4 | glue::glue("/repos/{matching_issue$repo}/issues/{matching_issue$number}/comments") 5 | ) 6 | 7 | initial_comment <- extract_comments( 8 | matching_issue 9 | ) 10 | comments <- extract_comments( 11 | result 12 | ) 13 | 14 | structure( 15 | list( 16 | title = matching_issue$title, 17 | author = matching_issue$author, 18 | repo = matching_issue$repo, 19 | number = matching_issue$number, 20 | labels = matching_issue$labels, 21 | thread = 22 | c( 23 | list(initial_comment), 24 | comments 25 | ) 26 | ), 27 | class = "issue_thread" 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /R/get_maybe_cached_issue.R: -------------------------------------------------------------------------------- 1 | get_maybe_cached_issue <- function(document_context, issue_info) { 2 | 3 | doc_yaml <- rmarkdown::yaml_front_matter(document_context$path) 4 | 5 | get_issue_from_cached_search_results(doc_yaml, issue_info) %||% 6 | get_issue_from_issue_info(issue_info) 7 | } 8 | 9 | get_issue_from_cached_search_results <- function(doc_yaml, issue_info) { 10 | 11 | if (length(doc_yaml) == 0) return(NULL) 12 | 13 | search_cache_key <- doc_yaml$cache_key 14 | if (is.null(search_cache_key)) return(NULL) 15 | 16 | issue_search_results <- get_cached_result(search_cache_key) 17 | if (is.null(issue_search_results)) return(NULL) 18 | 19 | search_result_urls <- vapply( 20 | issue_search_results$issues, 21 | function(issue) issue$api_url, 22 | character(1) 23 | ) 24 | 25 | matching_issue <- 26 | issue_search_results$issues[search_result_urls == issue_info$api_url][[1]] 27 | 28 | matching_issue 29 | } 30 | 31 | get_issue_from_issue_info <- function(issue_info) { 32 | extract_issues(gh::gh( 33 | api_issue_query(issue_info$repo, issue_info$number) 34 | )) 35 | } -------------------------------------------------------------------------------- /R/gh_thread.R: -------------------------------------------------------------------------------- 1 | #' Open an issue thread 2 | #' 3 | #' Three types of arugments are supported for opening an issue thread: 4 | #' - no argument (NULL), an attempt is made to read a url for a thread from the clipboard 5 | #' - numeric argument, an attempt is made to open an issue with this number in 6 | #' the repo corresponding to the current local repository 7 | #' - A shorthand syntax: `"milesmcbain/capsule#12"` or `"capsule#12"` 8 | #' @param thread text representing a thread to open, or nothing to try the clipboard. See details 9 | #' @export 10 | gh_thread <- function(thread = NULL) { 11 | if (is.null(thread)) { 12 | message("attempting to read an issue thread url from clipboard...") 13 | input <- clipr::read_clip() 14 | if (is_gh_url(input)) { 15 | issue_info <- extract_issue_info_from_gh_url(input) 16 | } 17 | } else if (is.numeric(thread)) { 18 | repo <- get_repo_remote() 19 | issue_info <- 20 | structure( 21 | list( 22 | repo = repo, 23 | number = thread, 24 | api_url = gh_api_url(repo, thread) 25 | ), 26 | class = "issue_info" 27 | ) 28 | } else if (is_issue_shorthand(thread)) { 29 | issue_info <- extract_issue_info_from_shorthand(thread) 30 | } else { 31 | stop("Could not identify issue from context.") 32 | } 33 | issue <- get_issue_from_issue_info(issue_info) 34 | issue_thread <- get_issue_thread(issue) 35 | 36 | display_issue_thread(issue_thread) 37 | invisible(NULL) 38 | } 39 | -------------------------------------------------------------------------------- /R/github_issue.R: -------------------------------------------------------------------------------- 1 | #' A github issue Rmarkdown format 2 | #' 3 | #' A wraper for [rmarkdown::github_document()] that can peform actions on GitHub 4 | #' when rendering relating to issues and PRs including: 5 | #' - create an issue 6 | #' - update an issue title, body, and labels 7 | #' - comment on an issue 8 | #' - close an issue with a comment 9 | #' @param repo the repository create the issue on e.g. "milesmcbain/capsule", or 10 | #' "capsule". Name will be resolved against locally insalled packages and CRAN in 11 | #' the later case. 12 | #' @param number the issue number in the repository if performing a comment or update action 13 | #' @param labels the labels to set for the issue if performing create or update action 14 | #' @param action the type of action to perform: "create", "update", "comment". Only "comment" is valid for PRs. 15 | #' @param draft if TRUE the action is not performed after rendering the document to 16 | #' GitHub markdown - set to TRUE initially to give you a chance to preview 17 | #' markdown output. 18 | #' @param close_with_comment if TRUE close the issue thread with comment action (assuming you have appropriate repo permissions). 19 | #' @param fig_width passed to image dimension passed to [rmarkdown::github_document()] 20 | #' @param fig_height image dimension passed to image dimension passed to [rmarkdown::github_document()] 21 | #' @param dev graphics device argument passed to [rmarkdown::github_document()] 22 | #' @param df_print data.frame print method passed to [rmarkdown::github_document()] 23 | #' @param math_method latex style math expression rendering method passed [rmarkdown::github_document()] 24 | #' @param wrap argument passed to pandoc. Controls use of line breaks. One of 25 | #' "auto", "preserve" or "none". "preserve" will mostly not interfere with your 26 | #' text formatting. 27 | #' @export 28 | github_issue <- function( 29 | repo = NULL, 30 | number = NULL, 31 | labels = NULL, 32 | action = "create", # create | update | comment 33 | draft = TRUE, 34 | close_with_comment = FALSE, 35 | fig_width = 7, 36 | fig_height = 5, 37 | dev = "png", 38 | df_print = "default", 39 | math_method = "default", 40 | wrap = "preserve" # auto | none | preserve 41 | ) { 42 | if(is.null(repo)) { 43 | stop("'repo' option must be supplied") 44 | } 45 | if (!is.null(number) && !is.numeric(number)) { 46 | stop("unrecognisable issue number: ", number) 47 | } 48 | repo <- resolve_repo(repo, check_github_exists = FALSE) 49 | assert_github_exists(repo = repo, issue = number) 50 | if (is.null(number) && close_with_comment) { 51 | stop("Can't create a closed issue ('close_with_comment: yes' for new issue.)") 52 | } 53 | if (!(action %in% c("create", "update", "comment"))) { 54 | stop("action must be one of create, update or comment") 55 | } 56 | if (action == "create" && !is.null(number)) { 57 | stop("Supplied an issue number for 'create' action (default). Only applicable for 'update' or 'comment'") 58 | } 59 | upload_fun <- NULL 60 | if (draft) { 61 | upload_fun <- function(x) x 62 | } 63 | upload_fun <- upload_fun %||% knitr::opts_chunk$get("upload.fun") %||% knitr::imgur_upload 64 | 65 | 66 | github_document_format <- rmarkdown::github_document( 67 | fig_width = fig_width, 68 | fig_height = fig_height, 69 | dev = dev, 70 | df_print = df_print, 71 | math_method = math_method, 72 | html_preview = draft, 73 | pandoc_args = c("--wrap", wrap) 74 | ) 75 | 76 | github_document_format_pre_processor <- 77 | github_document_format$pre_processor %||% 78 | function(...) character(0L) 79 | 80 | github_document_format_post_processor <- 81 | github_document_format$post_processor %||% 82 | function(metadata, input_file, output_file, ...) output_file 83 | 84 | # Preprocessor: 85 | # 1.1 If action is 'comment' Remove everything up to and including the footer line output by 86 | # render_issue_footer() 87 | # 1.2 If action is 'create' Remove the yaml only 88 | # 1.3 If action is 'update' Remove everything below and includding `render_issue_body_footer()`. 89 | # - Remove the yaml 90 | # - Remove the attribution / time stamp line 91 | # 2. Call github_document preprocessor 92 | pre_processor <- function(metadata, input_file, ...) { 93 | input_lines <- 94 | xfun::read_utf8(input_file) %>% 95 | enc2utf8() 96 | 97 | output_lines <- 98 | switch( 99 | action, 100 | create = get_output_lines_for_create(input_lines), 101 | comment = get_output_lines_for_comment(input_lines), 102 | update = get_output_lines_for_update(input_lines, metadata$author), 103 | stop("Unknown issue action: ", action) 104 | ) 105 | 106 | xfun::write_utf8( 107 | output_lines, 108 | input_file 109 | ) 110 | github_document_format_pre_processor( 111 | metadata, 112 | input_file, 113 | ... 114 | ) 115 | } 116 | 117 | # Postprocessor: 118 | # 1. If draft is FALSE, submit the issue to GitHub 119 | # - If number is not null, it's for an existing thread as a comment 120 | # - If number is null is for a new issue thread 121 | # 2. Call github_document Postprocessor 122 | post_processor <- function(metadata, input_file, output_file, ...) { 123 | 124 | issue_body <- 125 | xfun::read_utf8(output_file) %>% 126 | glue::glue_collapse(sep = "\n") 127 | 128 | switch( 129 | action, 130 | create = github_issue_submit( 131 | repo = repo, 132 | title = metadata$title, 133 | body = issue_body, 134 | labels = labels, 135 | draft = draft 136 | ), 137 | comment = github_comment_submit( 138 | repo = repo, 139 | number = number, 140 | body = issue_body, 141 | draft = draft 142 | ), 143 | update = github_update_submit( 144 | repo = repo, 145 | number = number, 146 | title = metadata$title, 147 | body = issue_body, 148 | labels = labels, 149 | draft = draft 150 | ), 151 | stop("Unknown issue action: ", action) 152 | ) 153 | 154 | withr::with_envvar( 155 | c(RMARKDOWN_PREVIEW_DIR = get_pkg_user_dir()), 156 | github_document_format_post_processor( 157 | metadata, 158 | input_file, 159 | output_file, 160 | ... 161 | ) 162 | ) 163 | } 164 | 165 | # on exit: 166 | on_exit <- function() { 167 | if (close_with_comment && !draft) { 168 | assert_github_exists(repo, number) 169 | github_issue_close( 170 | repo = repo, 171 | number = number 172 | ) 173 | } 174 | } 175 | 176 | github_document_format$knitr$opts_knit <- 177 | modifyList(github_document_format$knitr$opts_knit %||% list(), list(upload.fun = upload_fun)) 178 | github_document_format$pre_processor <- pre_processor 179 | github_document_format$post_processor <- post_processor 180 | github_document_format$on_exit <- on_exit 181 | github_document_format 182 | 183 | } 184 | 185 | github_issue_submit <- function( 186 | repo, 187 | title, 188 | body, 189 | labels, 190 | draft = FALSE 191 | ) { 192 | 193 | query <- 194 | glue::glue("POST /repos/{repo}/issues") 195 | 196 | if (draft) { 197 | just_a_draft(query) 198 | return(invisible(NULL)) 199 | } 200 | 201 | res <- gh::gh( 202 | query, 203 | encode_issue_json( 204 | title = title, 205 | body = body, 206 | labels = labels 207 | ), 208 | .send_headers = c( 209 | Accept = "application/vnd.github.switcheroo-preview+json", 210 | "Content-Type" = "application/json" 211 | ) # because using manual JSON encoding so labels is always an array. 212 | ) 213 | 214 | message("\nGitHub issue created. See ", res$html_url) 215 | } 216 | 217 | github_comment_submit <- function(repo, number, body, draft = FALSE) { 218 | assertthat::assert_that( 219 | nzchar(body), 220 | msg = "Cannot submit an empty issue comment." 221 | ) 222 | query <- 223 | glue::glue("POST /repos/{repo}/issues/{number}/comments") 224 | 225 | if (draft) { 226 | just_a_draft(query) 227 | return(invisible(NULL)) 228 | } 229 | 230 | res <- gh::gh( 231 | query, 232 | body = body 233 | ) 234 | 235 | message("\nGitHub issue comment submitted. See ", res$html_url) 236 | } 237 | 238 | github_update_submit <- function(repo, number, title, body, labels, draft = FALSE) { 239 | 240 | query <- glue::glue("PATCH /repos/{repo}/issues/{number}") 241 | 242 | if (draft) { 243 | just_a_draft(query) 244 | return(invisible(NULL)) 245 | } 246 | 247 | 248 | res <- gh::gh( 249 | query, 250 | encode_issue_json( 251 | title = title, 252 | body = body, 253 | labels = labels 254 | ), 255 | .send_headers = c( 256 | Accept = "application/vnd.github.switcheroo-preview+json", 257 | "Content-Type" = "application/json" 258 | ) # because using manual JSON encoding so labels is always an array. 259 | ) 260 | 261 | message("\nGitHub issue update submitted. See ", res$html_url) 262 | } 263 | 264 | github_issue_close <- function(repo, number) { 265 | 266 | res <- gh::gh( 267 | glue::glue("POST /repos/{repo}/issues/{number}"), 268 | state = "closed" 269 | ) 270 | 271 | close_message <- glue::glue("{repo}#{number} was closed. ('close_with_comment: yes').") 272 | 273 | message(close_message) 274 | } 275 | 276 | get_output_lines_for_create <- function(input_lines) { 277 | yaml_fences <- 278 | grepl( 279 | "^---", 280 | input_lines 281 | ) %>% 282 | which() 283 | 284 | output_lines <- 285 | if (length(yaml_fences) >= 2) { 286 | footer_line <- yaml_fences[2] 287 | input_lines[-seq(footer_line)] 288 | } else { 289 | # Send everything 290 | input_lines 291 | } 292 | } 293 | 294 | get_output_lines_for_comment <- function(input_lines) { 295 | footer_line <- 296 | grepl(render_issue_footer(), input_lines) %>% 297 | which() 298 | 299 | input_lines[-seq(footer_line)] 300 | } 301 | 302 | get_output_lines_for_update <- function(input_lines, author) { 303 | delete_below <- 304 | grepl(render_issue_body_footer(), input_lines) %>% 305 | which() 306 | 307 | body_lines <- 308 | input_lines[1:(delete_below - 1)] 309 | 310 | footer_line <- 311 | grepl( 312 | glue::glue("@{author}"), 313 | input_lines 314 | ) %>% 315 | match(TRUE, .) 316 | 317 | if (is.na(footer_line)) { 318 | stop("Could not find author attribution line in issue for action 'update'.") 319 | } 320 | 321 | body_lines[-seq(footer_line)] %>% 322 | trim_leading_and_trailing_blanks() 323 | 324 | } 325 | 326 | just_a_draft <- function(query) message("\nDid not submit ", query, " to GitHub, set 'draft: no' to submit.\n") 327 | 328 | trim_leading_and_trailing_blanks <- function(text_lines) { 329 | line_mask <- rep(FALSE, length(text_lines)) 330 | line_mask[1] <- TRUE 331 | line_mask[length(text_lines)] <- TRUE 332 | lines_to_drop <- (text_lines == "") & line_mask 333 | 334 | text_lines[!lines_to_drop] 335 | } 336 | 337 | encode_issue_json <- function(title, body, labels) { 338 | jsonlite::toJSON( 339 | list( 340 | title = jsonlite::unbox(title), 341 | body = jsonlite::unbox(body), 342 | labels = labels %||% character(0) # this always needs to be an array (boxed) 343 | ) 344 | ) %>% 345 | charToRaw() 346 | } 347 | -------------------------------------------------------------------------------- /R/issue_thread.R: -------------------------------------------------------------------------------- 1 | issue_thread_filename <- function(issue_thread) { 2 | glue::glue("{gsub('/', '_', issue_thread$repo)}_{issue_thread$number}") 3 | } 4 | 5 | render_issue_thread_front_matter <- function(issue_thread) { 6 | front_matter_data <- 7 | list( 8 | title = issue_thread$title, 9 | author = issue_thread$author, 10 | output = list(`rmdgh::github_issue` = list( 11 | repo = issue_thread$repo, 12 | number = issue_thread$number, 13 | labels = issue_thread$labels, 14 | action = "comment", 15 | draft = TRUE, 16 | close_with_comment = FALSE 17 | )) 18 | ) 19 | 20 | paste0( 21 | "---\n", 22 | yaml::as.yaml(front_matter_data), 23 | "---\n" 24 | ) 25 | 26 | } 27 | render_issue_thread <- function(thread) { 28 | 29 | issue_body <- 30 | render_thread_comment(thread[[1]]) 31 | 32 | issue_comments <- 33 | lapply(thread[-1], function(comment) { 34 | render_thread_comment(comment) 35 | }) 36 | 37 | c( 38 | issue_body, 39 | render_issue_body_footer(), 40 | issue_comments 41 | ) %>% 42 | glue::glue_collapse(sep = "\n") 43 | 44 | } 45 | 46 | render_thread_comment <- function(comment) { 47 | 48 | info <- glue::glue( 49 | "*@{comment$author} wrote {time_diff(comment$created_at)}*" 50 | ) 51 | 52 | glue::glue( 53 | "{info}{strrep('-', max(80 - nchar(info), 3))}\n", 54 | "{comment$body}\n", 55 | .trim = FALSE 56 | ) 57 | 58 | } 59 | 60 | render_issue_thread <- function(thread) { 61 | 62 | issue_body <- 63 | render_thread_comment(thread[[1]]) 64 | 65 | issue_comments <- 66 | lapply(thread[-1], function(comment) { 67 | render_thread_comment(comment) 68 | }) 69 | 70 | c( 71 | issue_body, 72 | render_issue_body_footer(), 73 | issue_comments 74 | ) %>% 75 | glue::glue_collapse(sep = "\n") 76 | 77 | } 78 | 79 | render_thread_comment <- function(comment) { 80 | 81 | info <- glue::glue( 82 | "*@{comment$author} wrote {time_diff(comment$created_at)}*" 83 | ) 84 | 85 | glue::glue( 86 | "{info}{strrep('-', max(80 - nchar(info), 3))}\n", 87 | "{comment$body}\n", 88 | .trim = FALSE 89 | ) 90 | 91 | } 92 | render_issue_footer <- function() { 93 | "" 94 | } 95 | 96 | render_issue_thread_content <- function(issue_thread) { 97 | paste( 98 | render_issue_thread(issue_thread$thread), 99 | "\n", 100 | render_issue_footer(), 101 | collapse = "\n", 102 | sep = "" 103 | ) 104 | } 105 | 106 | render_issue_thread_document <- function(issue_thread) { 107 | paste0( 108 | render_issue_thread_front_matter(issue_thread), 109 | "\n", 110 | render_issue_thread_content(issue_thread) 111 | ) 112 | } 113 | 114 | 115 | display_issue_thread <- function(issue_thread) { 116 | 117 | document_name <- issue_thread_filename(issue_thread) 118 | 119 | create_temp_document( 120 | document_name, 121 | render_issue_thread_front_matter(issue_thread), 122 | render_issue_thread_content(issue_thread) 123 | ) |> 124 | rstudioapi::navigateToFile() 125 | 126 | } 127 | 128 | replace_issue_thread <- function(issue_thread, document_context) { 129 | 130 | rstudioapi::modifyRange( 131 | rstudioapi::document_range( 132 | rstudioapi::document_position(1, 0), 133 | rstudioapi::document_position(length(document_context$contents), Inf) 134 | ), 135 | render_issue_thread_document(issue_thread), 136 | id = document_context$id 137 | ) 138 | 139 | } 140 | 141 | construct_issue_thread <- function( 142 | issue, 143 | issue_comments 144 | ) { 145 | 146 | } -------------------------------------------------------------------------------- /R/make_issue_info.R: -------------------------------------------------------------------------------- 1 | make_issue_info <- function(reference, ...) UseMethod("make_issue_info", reference) 2 | 3 | #' @export 4 | make_issue_info.shortcode <- function(shortcode, ...) { 5 | service <- regmatches( 6 | shortcode, 7 | regexpr("(?<=`)[a-z]{2}", shortcode, perl = TRUE) 8 | ) 9 | 10 | repo <- regmatches( 11 | shortcode, 12 | regexpr("(?<=\\s)[A-Za-z0-9_./-]+", shortcode, perl = TRUE) 13 | ) 14 | 15 | issue_number <- regmatches( 16 | shortcode, 17 | regexpr("(?<=#)[0-9]+", shortcode, perl = TRUE) 18 | ) 19 | 20 | api_url <- switch( 21 | service, 22 | gh = gh_api_url(repo, issue_number), 23 | stop("unknown shortcode service: ", service) 24 | ) 25 | 26 | structure( 27 | list( 28 | repo = repo, 29 | number = issue_number, 30 | api_url = api_url 31 | ), 32 | class = "issue_info" 33 | ) 34 | } 35 | 36 | #' @export 37 | make_issue_info.hashref <- function(hashref, document_context) { 38 | 39 | issue_number <- regmatches( 40 | hashref, 41 | regexpr("[0-9]+", hashref) 42 | ) 43 | assert_is_rmd(document_context) 44 | doc_yaml <- rmarkdown::yaml_front_matter(document_context$path) 45 | repo <- doc_yaml$output$`rmdgh::github_issue`$repo %||% get_repo_remote() 46 | 47 | assert_github_exists(repo, issue = issue_number) 48 | 49 | api_url <- 50 | gh_api_url(repo, issue_number) 51 | 52 | structure( 53 | list( 54 | repo = repo, 55 | number = issue_number, 56 | api_url = api_url 57 | ), 58 | class = "issue_info" 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /R/navigate_issue_search_results.R: -------------------------------------------------------------------------------- 1 | 2 | #' @export 3 | issue_search_results_forward <- function() { 4 | 5 | issue_search_results <- get_search_results_from_yaml() 6 | 7 | if (issue_search_results$current_page == issue_search_results$max_page) { 8 | message("No further search result pages.") 9 | return() 10 | } 11 | 12 | new_result_object <- gh::gh_next(issue_search_results$result_obj) 13 | 14 | make_search_result( 15 | issues = extract_issues(new_result_object), 16 | result_obj = new_result_object, 17 | query = issue_search_results$query, 18 | query_description = issue_search_results$query_description, 19 | current_page = issue_search_results$current_page + 1L, 20 | max_page = issue_search_results$max_page, 21 | cache_key = issue_search_results$cache_key 22 | ) %>% 23 | update_cached_result(key = issue_search_results$cache_key) %>% 24 | display_issue_search_results() 25 | 26 | invisible(NULL) 27 | } 28 | 29 | #' @export 30 | issue_search_results_backward <- function() { 31 | 32 | issue_search_results <- get_search_results_from_yaml() 33 | 34 | if (issue_search_results$current_page == 1) { 35 | message("No prior search results pages.") 36 | return() 37 | } 38 | 39 | new_result_object <- gh::gh_prev(issue_search_results$result_obj) 40 | 41 | make_search_result( 42 | issues = extract_issues(new_result_object), 43 | result_obj = new_result_object, 44 | query = issue_search_results$query, 45 | query_description = issue_search_results$query_description, 46 | current_page = issue_search_results$current_page - 1L, 47 | max_page = issue_search_results$max_page, 48 | cache_key = issue_search_results$cache_key 49 | ) %>% 50 | update_cached_result(key = issue_search_results$cache_key) %>% 51 | display_issue_search_results() 52 | 53 | invisible(NULL) 54 | } 55 | 56 | #' @export 57 | issue_search_results_expand <- function() { 58 | matching_issue <- get_issue_from_cursor_context() 59 | render_issue_body(matching_issue) %>% 60 | insert_text_below_cursor_line() 61 | invisible(NULL) 62 | } 63 | 64 | 65 | #' @export 66 | jump_to_issue_thread <- function() { 67 | matching_issue <- get_issue_from_cursor_context() 68 | issue_thread <- get_issue_thread(matching_issue) 69 | 70 | display_issue_thread(issue_thread) 71 | invisible(NULL) 72 | } 73 | 74 | #' @export 75 | refresh_issue_thread <- function() { 76 | document_context <- rstudioapi::getSourceEditorContext() 77 | assert_is_rmd(document_context) 78 | issue_yaml <- rmarkdown::yaml_front_matter(document_context$path) 79 | assert_github_issue(issue_yaml) 80 | 81 | github_issue <- issue_yaml$output[[1]] 82 | issue <- 83 | gh::gh( 84 | api_issue_query(repo = github_issue$repo, number = github_issue$number) 85 | ) 86 | issue_thread <- 87 | get_issue_thread( 88 | extract_issues(issue) 89 | ) 90 | 91 | replace_issue_thread(issue_thread, document_context) 92 | 93 | } 94 | 95 | #' @export 96 | jump_to_issue_webpage <- function() { 97 | issue <- get_issue_from_cursor_context() 98 | utils::browseURL(issue$html_url) 99 | 100 | invisible(NULL) 101 | } 102 | 103 | match_issue_reference <- function(document_context) { 104 | content_line <- 105 | atcursor::get_cursor_line(document_context) 106 | 107 | cursor_col <- 108 | atcursor::get_cursor_col(rstudioapi::primary_selection(document_context)) 109 | 110 | match <- 111 | forward_match_shortcode(content_line, cursor_col) %||% 112 | forward_match_hashref(content_line, cursor_col) 113 | 114 | match 115 | } 116 | 117 | forward_match_regex <- function(content, position, regex) { 118 | match <- gregexec(regex, content)[[1]] 119 | 120 | if (all(match < 0)) { 121 | return(NULL) 122 | } 123 | 124 | match_starts <- match 125 | match_ends <- match_starts + attr(match, "match.length") - 1 # need to include first character at match start 126 | shortcode_start <- match[!(match_ends < position)] 127 | shortcode_end <- match_ends[!(match_ends < position)] 128 | 129 | if (length(shortcode_start) == 0) { 130 | return(NULL) 131 | } # no matches ahead of cursor 132 | 133 | substr(content, shortcode_start[[1]], shortcode_end[[1]]) 134 | } 135 | 136 | forward_match_shortcode <- function(content, position) { 137 | match <- forward_match_regex(content, position, "`[a-z]{2}\\s[A-Za-z0-9_./-]+#[0-9]+`") 138 | 139 | if (is.null(match)) { 140 | return(match) 141 | } 142 | 143 | structure( 144 | match, 145 | class = "shortcode" 146 | ) 147 | } 148 | 149 | forward_match_hashref <- function(content, position) { 150 | match <- forward_match_regex(content, position, "#[0-9]+\\b") 151 | 152 | if (is.null(match)) { 153 | return(match) 154 | } 155 | 156 | structure( 157 | match, 158 | class = "hashref" 159 | ) 160 | 161 | } 162 | 163 | get_issue_from_cursor_context <- function() { 164 | document_context <- rstudioapi::getSourceEditorContext() 165 | 166 | reference <- match_issue_reference(document_context) 167 | 168 | if (length(reference) == 0) { 169 | message("the cursor is not on or ahead of an issue code.") 170 | return(invisible(NULL)) 171 | } 172 | 173 | issue_info <- make_issue_info(reference, document_context) 174 | 175 | matching_issue <- get_maybe_cached_issue(document_context, issue_info) 176 | 177 | } 178 | 179 | get_search_results_from_yaml <- function() { 180 | active_doc <- rstudioapi::getSourceEditorContext() 181 | 182 | doc_yaml <- rmarkdown::yaml_front_matter(active_doc$path) 183 | if (length(doc_yaml) == 0) stop("Found no yaml front matter in the current document.") 184 | 185 | search_cache_key <- doc_yaml$cache_key 186 | if (is.null(search_cache_key)) stop("Found no cache_key in the document yaml front matter.") 187 | 188 | issue_search_results <- get_cached_result(search_cache_key) 189 | if (is.null(issue_search_results)) stop("cache_key matched no search results - they're probably expired. Search again!") 190 | 191 | issue_search_results 192 | } -------------------------------------------------------------------------------- /R/parse_issue_content.R: -------------------------------------------------------------------------------- 1 | parse_issue_content <- function(issue_content) { 2 | 3 | issue_lines <- strsplit(issue_content, "\r\n")[[1]] 4 | 5 | line_is_chunk <- vapply( 6 | issue_lines, 7 | get_chunk_line_tracker(), 8 | logical(1), 9 | USE.NAMES = FALSE 10 | ) 11 | 12 | is_chunk_start <- line_is_chunk & !lag_indicator(line_is_chunk) 13 | is_chunk_end <- line_is_chunk & !lead_indicator(line_is_chunk) 14 | is_code <- line_is_chunk & !is_chunk_start & !is_chunk_end 15 | 16 | line_leads_with_hash <- 17 | grepl("^\\s{0,3}#", issue_lines) 18 | # 4 or more spaces makes the output quoted 19 | 20 | is_header <- 21 | line_leads_with_hash & !line_is_chunk 22 | 23 | data.frame( 24 | line_number = seq_along(issue_lines), 25 | line = issue_lines, 26 | is_chunk = line_is_chunk, 27 | is_chunk_start = is_chunk_start, 28 | is_code = is_code, 29 | is_chunk_end, 30 | is_header 31 | ) 32 | } 33 | 34 | get_chunk_line_tracker <- function() { 35 | in_code <- FALSE 36 | 37 | function(line) { 38 | if (grepl("^```", line)) in_code <<- !in_code 39 | in_code 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /R/register_reprex_knitr_engine.R: -------------------------------------------------------------------------------- 1 | 2 | register_reprex_knitr_engine <- function() { 3 | knitr::knit_engines$set(reprex = function(options) { 4 | 5 | if (length(options$code) == 0) { 6 | stop("No code supplied to {reprex} chunk") 7 | } 8 | 9 | reprex_code <- if(length(options$code) == 1) { 10 | c(options$code, "\n") 11 | # so that if user supplied 1 line of code it's not interpreted as file path 12 | } else { 13 | options$code 14 | } 15 | 16 | 17 | reprex_output <- 18 | reprex::reprex( 19 | input = reprex_code, 20 | venue = "gh", 21 | html_preview = FALSE, 22 | tidyverse_quiet = TRUE, 23 | style = options$style %||% FALSE, 24 | std_out_err = options$std_out_err %||% FALSE, 25 | session_info = options$session_info %||% FALSE 26 | ) 27 | 28 | glue::glue_collapse(reprex_output, sep = "\n") 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /R/render_issue_body.R: -------------------------------------------------------------------------------- 1 | render_issue_body <- function(issue) { 2 | 3 | glue::glue("-----\n", 4 | "*@{issue$author} wrote {time_diff(issue$created_at)} in {render_issue_shortcode(issue)}*:\n", 5 | "{issue$body}\n", 6 | "-----\n", 7 | .trim = FALSE) 8 | 9 | } 10 | -------------------------------------------------------------------------------- /R/render_issue_body_footer.R: -------------------------------------------------------------------------------- 1 | render_issue_body_footer <- function() { 2 | "" 3 | } 4 | -------------------------------------------------------------------------------- /R/render_issue_search_front_matter.R: -------------------------------------------------------------------------------- 1 | render_issue_search_front_matter <- function(result) { 2 | paste0( 3 | "---\n", 4 | yaml::as.yaml(result[c( 5 | "query", 6 | "cache_key", 7 | "current_page", 8 | "max_page" 9 | )]), 10 | "---\n" 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /R/render_issue_search_results.R: -------------------------------------------------------------------------------- 1 | render_issue_search_results <- function(issue_search_results) { 2 | 3 | issue_text <- lapply(issue_search_results, render_issue_one_line_description) 4 | glue::glue("- {issue_text}") %>% 5 | glue::glue_collapse(sep = "\n") 6 | } 7 | 8 | render_issue_one_line_description <- function(issue) { 9 | glue::glue(ifelse(issue$type == "pr", "(PR) ", ""), 10 | "{issue$title} ", 11 | render_issue_shortcode(issue), 12 | " {paste0(issue$labels, collapse = \", \")}") %>% 13 | trimws() 14 | } 15 | 16 | render_issue_shortcode <- function(issue) { 17 | glue::glue("`{service_from_base_url(issue$html_url)} {issue$repo}#{issue$number}`") 18 | } -------------------------------------------------------------------------------- /R/render_issue_summaries.R: -------------------------------------------------------------------------------- 1 | 2 | render_issue_summaries <- function(issue_items) { 3 | vapply( 4 | issue_items, 5 | render_issue_summary, 6 | character(1), 7 | USE.NAMES = FALSE 8 | ) |> 9 | paste0( 10 | collapse = "\r\n" 11 | ) 12 | } 13 | 14 | render_issue_summary <- function(issue_json) { 15 | issue_content <- 16 | c( 17 | issue_title(issue_json$title, issue_json$number), 18 | issue_meta_summary( 19 | issue_json$user$login, 20 | issue_json$created_at, 21 | issue_json$comments, 22 | issue_json$reactions$total_count), 23 | empty_line(), 24 | issue_body_summary(issue_json$body), 25 | empty_line(), 26 | issue_url(issue_json$html_url), 27 | horizontal_rule(), 28 | empty_line() 29 | ) 30 | 31 | paste0( 32 | issue_content, 33 | collapse = "\r\n" 34 | ) 35 | } 36 | 37 | issue_title <- function(title, number) { 38 | glue::glue("# {title} ({number})") 39 | } 40 | 41 | issue_meta_summary <- function(author, created_at, n_comments, n_reactions) { 42 | glue::glue( 43 | "Created {time_diff(created_at)}", 44 | "by @{author}", 45 | "{n_comments} comments", 46 | "{n_reactions} reactions", 47 | .sep = " " 48 | ) 49 | } 50 | 51 | issue_body_summary <- function(issue_body) { 52 | if (is.null(issue_body)) return(NULL) 53 | body_lines <- strsplit(issue_body, "\r\n")[[1]] 54 | body_lines_head <- utils::head(body_lines, 8) 55 | paste0(strrep(" ", 4), body_lines_head) # makes it quoted 56 | } 57 | 58 | empty_line <- function() "" 59 | 60 | issue_url <- function(issue_url) issue_url 61 | 62 | horizontal_rule <- function() strrep("-", 80) -------------------------------------------------------------------------------- /R/resolve_package_repo.R: -------------------------------------------------------------------------------- 1 | resolve_repo <- function(repo, check_github_exists = TRUE) { 2 | if (is_qualified_repo_name(repo)) { 3 | # e.g. tidyverse/dplyr 4 | if (check_github_exists) assert_github_exists(repo = repo) 5 | repo 6 | # if it's not a qualified name, try to resolve it as an R package name 7 | } else if (is_r_package_installed_locally(repo)) { 8 | resolve_package_from_local_lib(repo) 9 | } else { 10 | assert_CRAN_page_exists(repo) 11 | resolve_package_from_CRAN(repo) 12 | } 13 | } 14 | 15 | is_qualified_repo_name <- function(repo) { 16 | grepl("^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", repo) 17 | } 18 | 19 | 20 | is_r_package_installed_locally <- function(package) { 21 | tryCatch( 22 | length(find.package(package)) > 0, 23 | error = function(e) FALSE 24 | ) 25 | } 26 | 27 | resolve_package_from_local_lib <- function(package) { 28 | package_data <- 29 | find.package(package) %>% 30 | file.path("DESCRIPTION") %>% 31 | read.dcf() %>% 32 | as.data.frame() 33 | 34 | resolve_from_package_data(package_data) 35 | } 36 | 37 | is_github_url <- function(url) { 38 | if (is.null(url) || is.na(url)) { 39 | return(FALSE) 40 | } 41 | grepl("github.com", url) 42 | } 43 | 44 | 45 | get_repo_from_url <- function(url) { 46 | regexpr( 47 | "github.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", 48 | url, 49 | ignore.case = TRUE 50 | ) %>% 51 | regmatches( 52 | url, 53 | . 54 | ) %>% 55 | gsub( 56 | "github.com/", 57 | "", 58 | ., 59 | ignore.case = TRUE 60 | ) 61 | } 62 | 63 | resolve_from_package_data <- function(package_data) { 64 | 65 | if (!is.null(package_data$RemoteType) && package_data$RemoteType == "github") { 66 | repo <- glue::glue("{package_data$RemoteUsername}/{package_data$RemoteRepo}") 67 | } 68 | else if (is_github_url(package_data$BugReports)) { 69 | repo <- get_repo_from_url(package_data$BugReports) 70 | } else if (is_github_url(package_data$RemoteUrl)) { 71 | repo <- get_repo_from_url(package_data$RemoteUrl) 72 | } else { 73 | stop( 74 | "Couldn't resolve Github URL for: ", 75 | package_data$Package, 76 | ".", 77 | " Try using a qualified name e.g. /" 78 | ) 79 | } 80 | 81 | assert_github_exists(repo = repo) 82 | repo 83 | } 84 | 85 | resolve_package_from_CRAN <- function(package) { 86 | CRAN_tables <- 87 | rvest::read_html( 88 | cran_url(package) 89 | ) %>% 90 | rvest::html_table() 91 | 92 | package_data <- 93 | CRAN_tables[[1]] %>% 94 | stats::setNames(c("field", "value")) %>% 95 | tidyr::pivot_wider(names_from = field, values_from = value) %>% 96 | stats::setNames(., gsub(":", "", colnames(.))) 97 | 98 | resolve_from_package_data(package_data) 99 | } 100 | 101 | cran_url <- function(package) { 102 | glue::glue("https://cran.r-project.org/package={package}") 103 | } 104 | 105 | package_name <- function(resolved_package) { 106 | gsub("^[A-Za-z0-9-]+/", "", resolved_package) 107 | } 108 | -------------------------------------------------------------------------------- /R/results_cache.R: -------------------------------------------------------------------------------- 1 | globalVariables("RESULTS_CACHE") 2 | 3 | RESULTS_CACHE <- new.env() 4 | 5 | cache_result <- function(gh_result_object) { 6 | key <- uuid::UUIDgenerate() 7 | gh_result_object$cache_key <- key 8 | assign(key, gh_result_object, envir = RESULTS_CACHE) 9 | gh_result_object 10 | } 11 | 12 | update_cached_result <- function(gh_result_object, key) { 13 | assign(key, gh_result_object, envir = RESULTS_CACHE) 14 | gh_result_object 15 | } 16 | 17 | get_cached_result <- function(key) { 18 | tryCatch( 19 | get(key, envir = RESULTS_CACHE), 20 | error = function(e) NULL 21 | ) 22 | } 23 | 24 | 25 | clear_results_cache <- function() { 26 | rm(list = ls(RESULTS_CACHE), envir = RESULTS_CACHE) 27 | } 28 | 29 | -------------------------------------------------------------------------------- /R/rmdgh-package.R: -------------------------------------------------------------------------------- 1 | #' @keywords internal 2 | "_PACKAGE" 3 | 4 | ## usethis namespace: start 5 | #' @importFrom magrittr %>% 6 | ## usethis namespace: end 7 | NULL 8 | -------------------------------------------------------------------------------- /R/save_issue.R: -------------------------------------------------------------------------------- 1 | #' Save an issue to the current working directory 2 | #' 3 | #' A helper for collecting issue documents locally to worked on at a later time. 4 | #' 5 | #' `folder` will be `./issues` by default but is configurable in option 6 | #' `rmdgh_issue_location`. If it does not exist, it is created and added to the 7 | #' `.Rbuildignore`. 8 | #' @param filename the name to give the issue. Defaulted from issue repo and number in yaml. 9 | #' @param folder where to store the issue. The default is "./issues". 10 | #' @export 11 | save_issue <- function(filename = NULL, folder = getOption("rmdgh_issue_location", "./issues")) { 12 | 13 | issue_context <- rstudioapi::getSourceEditorContext() 14 | assert_is_rmd(issue_context) 15 | 16 | issue_yaml <- rmarkdown::yaml_front_matter(issue_context$path) 17 | assert_github_issue(issue_yaml) 18 | 19 | if (is.null(filename)) { 20 | repo <- issue_yaml$output$`rmdgh::github_issue`$repo %||% "unknown_repo" 21 | number <- issue_yaml$output$`rmdgh::github_issue`$number %||% "draft" 22 | suffix <- ifelse(number == "draft", format(Sys.time(), "_%Y%m%d_%H%M%S"), "") 23 | filename <- glue::glue("{gsub('/', '_', repo)}_{number}{suffix}.Rmd") 24 | } 25 | 26 | if (!dir.exists(folder)) { 27 | usethis::use_directory(folder, ignore = TRUE) 28 | } 29 | output_path <- file.path(folder, filename) 30 | 31 | file.copy( 32 | issue_context$path, 33 | output_path, 34 | overwrite = FALSE 35 | ) 36 | 37 | message( 38 | glue::glue("Saved {issue_context$path} as {output_path}.") 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /R/search.R: -------------------------------------------------------------------------------- 1 | get_gh_email <- function() { 2 | email <- tryCatch( 3 | { 4 | gh_config <- gert::git_config() 5 | gh_config[gh_config$name == "user.email", "value"] 6 | }, 7 | error = function(x) { 8 | NULL 9 | } 10 | ) 11 | 12 | if (is.null(email)) { 13 | gh_config_global <- gert::git_config_global() 14 | email <- gh_config_global[gh_config_global$name == "user.email", "value"] 15 | } 16 | 17 | if (nrow(email) == 0) { 18 | stop("Couldn't find user.email it git config") 19 | } 20 | email$value 21 | } 22 | 23 | #' Get the github user using local git email config. 24 | #' @export 25 | get_gh_user <- function() { 26 | user_email <- get_gh_email() 27 | res <- gh::gh( 28 | "/search/users", 29 | q = glue::glue("{user_email} in:email") 30 | ) 31 | if (res$total_count == 0) stop("Could not find a GitHub user for ", user_email) 32 | if (res$total_count > 1) warning("Found more than one GitHub user for ", user_email) 33 | res$items[[1]]$login 34 | } 35 | 36 | 37 | #' @export 38 | get_repo_remote <- function() { 39 | tryCatch( 40 | { 41 | remote <- gh::gh_tree_remote() 42 | glue::glue("{remote$username}/{remote$repo}") 43 | }, 44 | error = function(x) NULL 45 | ) 46 | } 47 | 48 | is_open_qualifier <- function(is_open) { 49 | if (is.null(is_open)) { 50 | return(NULL) 51 | } 52 | if (is_open) "is:open" else "is:closed" 53 | } 54 | 55 | when_supplied_make_kvp_else_null <- function(value, key) { 56 | if (!is.null(value) && length(value) > 0) { 57 | paste0(key, ":", "\"", value, "\"", collapse = " ") 58 | } else { 59 | NULL 60 | } 61 | } 62 | 63 | make_query_arg_list <- function( 64 | repos = NULL, 65 | search_query = NULL, 66 | type = "issue", 67 | search_in = c("title", "body"), 68 | author = NULL, 69 | involves = NULL, 70 | is_open = TRUE, 71 | label = NULL, 72 | query_description = NULL, 73 | order = "desc", 74 | extra_params = NULL 75 | ) { 76 | as.list(environment()) 77 | } 78 | 79 | make_search_result <- function( 80 | issues, 81 | result_obj, 82 | query, 83 | query_description, 84 | cache_key, 85 | current_page, 86 | max_page 87 | ) { 88 | call_args <- as.list(environment()) 89 | structure( 90 | call_args, 91 | class = "issue_search_result" 92 | ) 93 | } 94 | 95 | issue_query <- function( 96 | repos = NULL, 97 | search_query = NULL, 98 | type = "issue", 99 | search_in = c("title", "body"), 100 | author = NULL, 101 | involves = NULL, 102 | is_open = TRUE, 103 | label = NULL, 104 | query_description = NULL, 105 | order = "desc", 106 | extra_params = NULL 107 | ) { 108 | call_args <- as.list(environment()) 109 | resolved_repos <- lapply(repos, resolve_repo) 110 | 111 | if (!is.null(repos)) { 112 | call_args$repos <- resolved_repos 113 | } 114 | 115 | repos_kvp <- when_supplied_make_kvp_else_null(resolved_repos, "repo") 116 | author_kvp <- when_supplied_make_kvp_else_null(author, "author") 117 | involves_kvp <- when_supplied_make_kvp_else_null(involves, "involves") 118 | type_kvp <- when_supplied_make_kvp_else_null(type, "type") 119 | in_kvp <- when_supplied_make_kvp_else_null(search_in, "in") 120 | label_kvp <- when_supplied_make_kvp_else_null(label, "label") 121 | is_open_kvp <- is_open_qualifier(is_open) 122 | 123 | issue_search_query <- 124 | paste( 125 | search_query, 126 | repos_kvp, 127 | author_kvp, 128 | involves_kvp, 129 | type_kvp, 130 | in_kvp, 131 | label_kvp, 132 | is_open_kvp, 133 | paste(extra_params, collapse = " ") 134 | ) 135 | 136 | result <- gh::gh( 137 | "/search/issues", 138 | q = glue::glue( 139 | issue_search_query 140 | ), 141 | per_page = getOption("issue_search_results_per_page", 30), 142 | sort = "updated", 143 | order = order 144 | ) 145 | 146 | make_search_result( 147 | issues = extract_issues(result), 148 | result_obj = result, 149 | query = do.call(make_query_arg_list, call_args), 150 | query_description = query_description, 151 | current_page = 1L, 152 | max_page = as.integer(ceiling(result$total_count / getOption("issue_search_results_per_page", 30))) 153 | ) %>% 154 | cache_result() 155 | } 156 | 157 | return_search_result <- function(result) { 158 | if (length(result$issues) > 0) { 159 | display_issue_search_results(result) 160 | } else { 161 | message("No issue search results.") 162 | } 163 | } 164 | 165 | #' @describeIn issues issues for the local repository 166 | #' @param ... arguments passed to [issues()] 167 | #' @export 168 | repo_issues <- function( 169 | repos = get_repo_remote(), 170 | query_description = glue::glue("repository issues for {paste(repos, collapse = \" \")}"), 171 | ... 172 | ) { 173 | if (is.null(repos)) { 174 | stop( 175 | "'repos' was not supplied", 176 | "and could not be defaulted from current working directory." 177 | ) 178 | } 179 | 180 | issues( 181 | repos = repos, 182 | query_description = query_description, 183 | ... 184 | ) 185 | } 186 | 187 | 188 | #' @describeIn issues PRs for the local repository 189 | #' @param ... arguments passed to [issues()] 190 | #' @export 191 | repo_prs <- function( 192 | repos = get_repo_remote(), 193 | query_description = glue::glue("repository PRs for {paste(repos, collapse = \" \")}"), 194 | ... 195 | ) { 196 | repo_issues( 197 | repos = repos, 198 | query_description = query_description, 199 | type = "pr", 200 | ... 201 | ) 202 | } 203 | 204 | #' @describeIn issues issues authored by you 205 | #' @param ... arguments passed to [issues()] 206 | #' @export 207 | my_issues <- function( 208 | author = get_gh_user(), 209 | query_description = glue::glue("{paste(author, collapse = \" \")} issues"), 210 | ... 211 | ) { 212 | issues( 213 | author = author, 214 | query_description = query_description, 215 | ... 216 | ) 217 | } 218 | 219 | #' @describeIn issues issues referring to you 220 | #' @param ... arguments passed to [issues()] 221 | #' @export 222 | issues_with_me <- function( 223 | involves = get_gh_user(), 224 | query_description = glue::glue("issues with {paste0(involves)}"), 225 | ... 226 | ) { 227 | issues( 228 | involves = involves, 229 | query_description = query_description, 230 | ... 231 | ) 232 | } 233 | 234 | 235 | #' @describeIn issues PRs by you 236 | #' @param ... arguments passed to [issues()] 237 | #' @export 238 | my_prs <- function( 239 | author = get_gh_user(), 240 | type = "pr", 241 | query_description = glue::glue("PRs by {paste0(author)}"), 242 | ... 243 | ) { 244 | issues( 245 | author = author, 246 | type = type, 247 | query_description = query_description, 248 | ... 249 | ) 250 | } 251 | 252 | #' @describeIn issues PRs referring to you 253 | #' @param ... arguments passed to [issues()] 254 | #' @export 255 | prs_with_me <- function( 256 | involves = get_gh_user(), 257 | type = "pr", 258 | query_description = glue::glue("PRs with {paste0(involves)}"), 259 | ... 260 | ) { 261 | issues( 262 | involves = involves, 263 | type = type, 264 | query_description = query_description, 265 | ... 266 | ) 267 | } 268 | 269 | #' @describeIn issues PRs in repositories you own 270 | #' @param user the user to return issues or PRs for 271 | #' @param ... arguments passed to [issues()] 272 | #' @export 273 | prs_for_me <- function( 274 | user = get_gh_user(), 275 | type = "pr", 276 | query_description = glue::glue("PRs for {paste0(user)}"), 277 | extra_params = glue::glue("user:{user}"), 278 | ... 279 | ) { 280 | issues( 281 | type = type, 282 | query_description = query_description, 283 | extra_params = extra_params, 284 | ... 285 | ) 286 | } 287 | 288 | #' @describeIn issues issues in repositories you own 289 | #' @param user the user to return issues or PRs for 290 | #' @param ... arguments passed to [issues()] 291 | #' @export 292 | issues_for_me <- function ( 293 | user = get_gh_user(), 294 | type = "issue", 295 | query_description = glue::glue("Issues for {paste0(user)}"), 296 | extra_params = glue::glue("user:{user}"), 297 | ... 298 | ) { 299 | issues( 300 | type = type, 301 | query_description = query_description, 302 | extra_params = extra_params, 303 | ... 304 | ) 305 | } 306 | 307 | #' @describeIn issues all issues and PRs in repositories you own. 308 | #' @param user the user to return issues or PRs for 309 | #' @param ... arguments passed to [issues()] 310 | #' @export 311 | gh_for_me <- function ( 312 | user = get_gh_user(), 313 | type = NULL, 314 | query_description = glue::glue("Issues and PRs for {paste0(user)}"), 315 | extra_params = glue::glue("user:{user}"), 316 | ... 317 | ) { 318 | issues( 319 | type = type, 320 | query_description = query_description, 321 | extra_params = extra_params, 322 | ... 323 | ) 324 | } 325 | 326 | #' Search Issues and PRs and present results in Rmarkdown 327 | #' 328 | #' `issues()` and its user-friendly wrappers are designed to allow you quick 329 | #' access to lists of issues and PRs that you can use as a jumping off point for 330 | #' exploring Rmarkdown issue threads. 331 | #' 332 | #' Results are paged according to `getOption('issue_search_results_per_page')`. 333 | #' 334 | #' Navigate between pages with [issue_search_results_forward()] and [issue_search_results_backward()]. 335 | #' 336 | #' Previw an issue thread inline with search results with [issue_search_results_expand()]. 337 | #' 338 | #' Use [jump_to_issue_thread()] to jump to an Rmarkdown thread based on the 339 | #' cursor position or [jump_to_issue_webpage()] to jump to the web. 340 | #' 341 | #' @param repos a character vector issue repositories to get issues from. "owner/repo" 342 | #' and "repo" are allowed, with the repo resolved against 343 | #' installed R packages and CRAN in the latter case. 344 | #' @param search_query a character string to search for in issue titles and bodies. 345 | #' @param type `"issue"` or `"pr"` or NULL for both kinds. 346 | #' @param search_in where to apply `search query`. Any combination of `"title"` or `"body"`. The default is `c("title", "body")` for both. 347 | #' @param author a character vector of issue authors to return issues by. 348 | #' @param involves a character vector of users to find in issue/PR threads, filtering returned results. 349 | #' @param is_open TRUE for open issues, FALSE for closed, NULL for both. 350 | #' @param label a character vector of issue labels to filter reutrned results. 351 | #' @param query_description describe this query (appears in Rmarkdown results). Useful for building higher level functionality. 352 | #' @param order `"asc"` or `"desc"` - the ordering of search results by last update date. 353 | #' @param extra_params a character vector of extra query parameters that are passed verbatim to the github API. This take the form `"key:value"` e.g. `org:reditorsupport`. 354 | #' @export 355 | issues <- function( 356 | repos = NULL, 357 | search_query = NULL, 358 | type = "issue", 359 | search_in = c("title", "body"), 360 | author = NULL, 361 | involves = NULL, 362 | is_open = TRUE, 363 | label = NULL, 364 | query_description = NULL, 365 | order = "desc", 366 | extra_params = NULL 367 | ) { 368 | issue_args <- as.list(environment()) 369 | do.call(issue_query, issue_args) %>% 370 | return_search_result() 371 | } 372 | -------------------------------------------------------------------------------- /R/utils.R: -------------------------------------------------------------------------------- 1 | gh_url <- function(path) { 2 | glue::glue("https://github.com/{path}") 3 | } 4 | 5 | 6 | gh_api_url <- function(repo, issue_number) { 7 | glue::glue( 8 | "https://api.github.com/repos/{repo}/issues/{issue_number}" 9 | ) 10 | } 11 | 12 | lead_indicator <- function(indicator_vec) { 13 | c( 14 | indicator_vec[-1], 15 | FALSE 16 | ) 17 | } 18 | 19 | lag_indicator <- function(indicator_vec) { 20 | c( 21 | FALSE, 22 | indicator_vec[-length(indicator_vec)] 23 | ) 24 | } 25 | 26 | time_diff <- function(timestamp) { 27 | prettyunits::vague_dt( 28 | lubridate::now() - lubridate::as_datetime(timestamp) 29 | ) 30 | } 31 | 32 | service_from_base_url <- function(url) { 33 | if (grepl("github.com", url, ignore.case = TRUE)) { 34 | "gh" 35 | } else { 36 | stop("Unrecognised issue URL!") 37 | } 38 | } 39 | 40 | flatten_char <- function(char_vec) { 41 | if (is.null(char_vec)) { 42 | return(NULL) 43 | } 44 | paste0(char_vec, collapse = ", ") 45 | } 46 | 47 | `%||%` <- function(lhs, rhs) if (is.null(lhs)) rhs else lhs 48 | 49 | api_issue_query <- function(repo, number) { 50 | glue::glue("/repos/{repo}/issues/{number}") 51 | } 52 | 53 | is_gh_url <- function(input) { 54 | if (length(input) > 1 && !is.character(input)) { 55 | return(FALSE) 56 | } 57 | grepl("^(https|http)://", input) && 58 | grepl("github.com", input) 59 | } 60 | 61 | extract_issue_info_from_gh_url <- function(gh_url) { 62 | result <- regmatches( 63 | gh_url, 64 | regexec( 65 | "github.com/(?P[A-Za-z.-_]+)/(?P[A-Za-z.-_]+)/(?Pissues|pulls)/(?P[0-9]+)", 66 | gh_url, 67 | perl = TRUE 68 | ) 69 | )[[1]] 70 | 71 | repo <- 72 | paste(result["owner"], result["repo"], sep = "/") 73 | number <- 74 | unname(result["number"]) 75 | 76 | structure( 77 | list( 78 | repo = repo, 79 | number = number, 80 | api_url = gh_api_url(repo, number) 81 | ), 82 | class = "issue_info" 83 | ) 84 | } 85 | 86 | is_issue_shorthand <- function(text) { 87 | if (length(text) > 1 && !is.character(text)) { 88 | return(FALSE) 89 | } 90 | grepl("[A-Za-z.-_]+/[A-Za-z.-_]+#[0-9]+", text) | 91 | grepl("[A-Za-z.-_]+#[0-9]+", text) 92 | } 93 | 94 | extract_issue_info_from_shorthand <- function(text) { 95 | result <- regmatches( 96 | text, 97 | regexec( 98 | "(?P[A-Za-z.-_]+)/(?P[A-Za-z.-_]+)#(?P[0-9]+)", 99 | text, 100 | perl = TRUE 101 | ) 102 | )[[1]] 103 | 104 | if (is.na(result["repo"])) { 105 | result <- regmatches( 106 | text, 107 | regexec( 108 | "(?P[A-Za-z.-_]+)#(?P[0-9]+)", 109 | text, 110 | perl = TRUE 111 | ) 112 | )[[1]] 113 | 114 | if (is.na(result["repo"]) | is.na(result["number"])) { 115 | stop("Valid shorthand syntax is # or /#number") 116 | } 117 | 118 | repo <- resolve_repo(result["repo"]) 119 | } else { 120 | repo <- 121 | paste(result["owner"], result["repo"], sep = "/") 122 | } 123 | 124 | number <- 125 | unname(result["number"]) 126 | 127 | structure( 128 | list( 129 | repo = repo, 130 | number = number, 131 | api_url = gh_api_url(repo, number) 132 | ), 133 | class = "issue_info" 134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # rmdgh 3 | 4 | What if there exists an alternate timeline where (R)markdown is their HTML? 5 | 6 | Imagine the overbearing richness of the modern web pared back to glorious 7 | responsive plain text interfaces... And how the inhabitants of this brave world would 8 | interact with their favourite centralised decentralised version control 9 | repository? 10 | 11 | We can't yet go to this utopia, but we can: 12 | 13 | ```r 14 | install.packages( 15 | "rmdgh", 16 | repos = c(mm = "https://milesmcbain.r-universe.dev", getOption("repos"))) 17 | ``` 18 | 19 | To get an R <-> GitHub productivity tool powered by Rmarkdown that works in VSCode or RStudio via `{rstudioapi}`. 20 | 21 | ## Search Operations 22 | 23 | - `my_issues()` issues by author (defaults to you) 24 | - `issues_with_me()` issues referring to author (defaults to you) 25 | - `issues_for_me()` issues created on an user's repositories (defaults to you) 26 | - `repo_issues()` issues for a given repo or repos (defaults to current repo) 27 | - `my_prs()` pr threads by an author (defaults to you) 28 | - `prs_with_me()` pr threads referring to author (defaults to you) 29 | - `prs_for_me()` pr threads for user's repos (defaults to you) 30 | - `repo_prs()` prs for a given repo or repos (defaults to current repo) 31 | - `gh_for_me()` prs and issues for user's repos (defaults to you) 32 | 33 | You can change who 'me' or 'my' is. It defaults to you but if you do: 34 | 35 | ```r 36 | my_issues( 37 | repos = "tidyverse/dplyr", 38 | author = "hadley" 39 | ) 40 | ``` 41 | And you'll get Hadley's issues for `{dplyr}`. 42 | 43 | `repos` accepts multiple repos. 44 | 45 | All of these are built on top of `issues()` which takes various arguments for 46 | searching and filtering issues and PRs to return to you. 47 | 48 | ### Search with text queries 49 | 50 | All search operations support `search_query` which is text to search in the title and body of issues to filter search results. You can use this to find issues relating to problems you may be having. e.g.: 51 | 52 | ```r 53 | repo_issues( 54 | repos = "rdatatable/data.table", 55 | search_query = "names(DT) reference semantics" 56 | ) 57 | ``` 58 | 59 | ### Search Options 60 | 61 | - `issue_search_results_per_page` Defines the search results per page. Defaults to 30. Max is 100. 62 | 63 | ## Navigation Operations 64 | 65 | You want to bind most of these to keys: 66 | 67 | - `issue_seach_results_forward()` 68 | - get the next page of issue search results 69 | - `issue_search_results_backward()` 70 | - get the previous page of search results 71 | - `issue_search_results_expand()` 72 | - search forward on the current line for the identifier of an issue and if found preview the issue body inline with search results. 73 | - `jump_to_issue_thread()` 74 | - Search as per 'expand', but go to the issue thread rendered as an Rmd document. You can submit updates to the issue, comments to the thread, or close the issue. 75 | - `jump_to_issue_webpage()` 76 | - Search as per `expand`, but go to the issue thread on GitHub 77 | - `refresh_issue_thread()` 78 | - refresh the issue thread referred to by the current Rmd. 79 | 80 | There is also `gh_thread()` to open issue threads as Rmarkdown documents, which is designed for console use. E.g. 81 | 82 | ```r 83 | gh_thread("capsule#12") 84 | gh_thread("milesmcbain/capusle#12") 85 | gh_thread() # read url from the clipboard 86 | gh_thread(1) # issue 1 in the current repo 87 | ``` 88 | 89 | ## GitHub issue thread RMarkdown output 90 | 91 | We have an Rmarkdown output format called `github_issue` that can be used to submit issues, issue updates, and issue comments to GitHub when the document is rendered with `rmarkdown::render()`, the `knit` button in RStudio, or the `Knit Rmd` command in VSCode. 92 | 93 | ### Drafting issues 94 | 95 | - `draft_issue()` Will create a new RMarkdown GitHub issue, defaulting to the current repo. By default issues are created in a temporary directory but the path can be changed with option `rmdgh_issue_draft_path`. 96 | 97 | Config you can use looks something like this: 98 | 99 | ``` 100 | --- 101 | title: Example 102 | author: MilesMcBain 103 | output: 104 | rmdgh::github_issue: 105 | repo: MilesMcBain/rmdgh 106 | number: 8 107 | labels: ~ 108 | action: comment 109 | draft: no 110 | close_with_comment: no 111 | --- 112 | ``` 113 | 114 | - `action` is one of 'create', 'update', 'comment' 115 | - `number` is only valid with 'update' or 'comment' 116 | - `action: update` lets you update the issue title, body, and labels. Comments cannot be updated. 117 | - `labels` can be a single label or yaml list of labels. Only used on 'create' and 'update'. 118 | 119 | ### Commenting on Issues (and PRs) 120 | 121 | You can type in the necessary metadata to make a comment in the draft you're given above. But it's much nicer to navigate to the issue thread with `jump_to_issue_thread()` described above. Metadata is automatically set up for to submit a comment on render in this case. 122 | 123 | ### Making a reprex 124 | 125 | `reprex::reprex()` doesn't really work well inside a code chunk. You may not even need it though, since rendering a `github_issue()` does a similar thing to `{reprex}` so long as you render it in a fresh session. 126 | 127 | You can use `error = TRUE` in the chunk options to display error output instead of stopping. 128 | 129 | However, I decided to make a `{reprex}` `{knitr}` engine, since I think the specific output from the `{reprex}` package is expected in some communities, and could cause confusion if it is absent. 130 | 131 | So with the new engine you can make a code chunk that uses `{reprex}` instead of 132 | `{r}`. The output will be as if you had called `reprex::reprex()` on the code in 133 | that chunk. Code in these chunks is self-contained, as per regular reprexes. 134 | 135 | ### Uploading images 136 | 137 | By default `github_issue` uses the same strategy as `{reprex}` for images, which is to upload them to Imgur with `knitr::imgur_upload()`. Note these images are public. 138 | 139 | Another image service can be used by configuring an alternative function in `knitr::opts_chunk$get(upload.fun =)`. See [Publish images from chunks in the web](https://yihui.org/knitr/demo/upload/). 140 | 141 | 142 | ## Saving Issues 143 | 144 | If you'd like to create a workflow where you stash some issues locally to work through checkout `save_issue()` for saving issues in a configurable location - defaulting to `./issues`. The location is configurable in the `rmdgh_issue_location` option. 145 | 146 | # FAQ 147 | 148 | ## Why? Why have you done this? 149 | 150 | I find typing into little text input boxes on GitHub.com a bit of a buzz kill 151 | when drafting issues. My text editor feels so much nicer to draft technical 152 | communications with. 153 | 154 | There's also a bit of jankyness that comes from the fact that most of the time 155 | when drafting issues I'll want a reprex, which has to be coded up in R, rendered, and then pasted into the issue. But sometimes as the issue evolves the 156 | reprex needs to also, and there's awkward iterative back and forth involving context switching 157 | between applications. 158 | 159 | This is exactly the kind of source-output synchronisation and context switching pain that `{knitr}` and `{rmarkdown}` take away. Why wouldn't we draft GitHub issues and comments that mash up code and prose in RMarkdown?! 160 | 161 | ## Will you port this to Quarto at some point? 162 | 163 | Possibly. I like the idea of being able to author issues that use multiple 164 | languages. However much of this package works via the `{rstudioapi}` which means 165 | it's not likely to appeal to quarto users from other languages. 166 | 167 | Probably a Quarto version should be implemented as VSCode extension? But then 168 | the potential user base is much smaller at present. Let's see what Posit does 169 | with extensions in the stand-alone Quarto editor that must surely be coming. 170 | -------------------------------------------------------------------------------- /discovery/Rmd.Rmd: -------------------------------------------------------------------------------- 1 | # Output formats 2 | 3 | Can be created using `rmarkdown::output_format()`: https://rmarkdown.rstudio.com/docs/reference/output_format.html -------------------------------------------------------------------------------- /discovery/gh.Rmd: -------------------------------------------------------------------------------- 1 | library(gh) 2 | 3 | # Resources 4 | 5 | - [Search API endpoint](https://docs.github.com/en/rest/search) 6 | - [Building a search query string](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests) 7 | - [Searching a user](https://docs.github.com/en/rest/search#search-users) 8 | - [Endpoint](https://docs.github.com/en/rest/search#search-users) 9 | 10 | 11 | # repository issue search 12 | 13 | ```r 14 | 15 | res <- gh( 16 | "/search/issues", 17 | q = "involves:miles.mcbain type:issue in:title,body repo:tidyverse/dplyr" 18 | ) 19 | 20 | ``` 21 | 22 | # find a user 23 | 24 | ```r 25 | 26 | res <- gh( 27 | "/search/users", 28 | q = "miles.mcbain in:email" 29 | ) 30 | 31 | ``` 32 | 33 | ## get user's email 34 | 35 | ```r 36 | 37 | library(gert) 38 | library(dplyr) 39 | gert::git_config() |> 40 | filter(name == "user.email") |> 41 | pull(value) 42 | 43 | ``` 44 | 45 | # make an issue comment 46 | 47 | https://docs.github.com/en/rest/issues/comments#create-an-issue-comment 48 | 49 | ```r 50 | repo <- "milesmcbain/testrpkg" 51 | number <- 2 52 | res <- gh::gh( 53 | glue::glue("POST /repos/{repo}/issues/{100}/comments"), 54 | body = "# test\n does this work?" 55 | ) 56 | res 57 | 58 | ``` 59 | 60 | # create an issue 61 | 62 | ```r 63 | repo <- "milesmcbain/testrpkg" 64 | title <- "submitted via API" 65 | body <- "# test\n does this work?" 66 | 67 | res <- gh::gh( 68 | glue::glue("POST /repos/{repo}/issues"), 69 | title = title, 70 | body = body 71 | ) 72 | 73 | res$html_url 74 | 75 | ) 76 | ``` 77 | 78 | # close the issue 79 | ```r 80 | repo <- "milesmcbain/testrpkg" 81 | number <- 3 82 | 83 | res <- gh::gh( 84 | glue::glue("POST /repos/{repo}/issues/{number}"), 85 | state = "closed" 86 | ) 87 | 88 | ``` 89 | 90 | # Harmonising extract_issues 91 | 92 | It would be good if extract_issues could work the same on search results and an 93 | individual issue API response. 94 | 95 | search_results look like this: 96 | 97 | ``` 98 | { 99 | "total_count": 5, 100 | "incomplete_results": false, 101 | "items": [ 102 | { 103 | "url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/13", 104 | "repository_url": "https://api.github.com/repos/MilesMcBain/rmdgh", 105 | "labels_url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/13/labels{/name}", 106 | "comments_url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/13/comments", 107 | "events_url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/13/events", 108 | "html_url": "https://github.com/MilesMcBain/rmdgh/issues/13", 109 | "id": 1346311357, 110 | "node_id": "I_kwDOHZOUYc5QPxS9", 111 | "number": 13, 112 | "title": "Jump and expand for local repo issue references", 113 | "user": { 114 | "login": "MilesMcBain", 115 | "id": 9996346, 116 | "node_id": "MDQ6VXNlcjk5OTYzNDY=", 117 | "avatar_url": "https://avatars.githubusercontent.com/u/9996346?v=4", 118 | "gravatar_id": "", 119 | "url": "https://api.github.com/users/MilesMcBain", 120 | "html_url": "https://github.com/MilesMcBain", 121 | "followers_url": "https://api.github.com/users/MilesMcBain/followers", 122 | "following_url": "https://api.github.com/users/MilesMcBain/following{/other_user}", 123 | "gists_url": "https://api.github.com/users/MilesMcBain/gists{/gist_id}", 124 | "starred_url": "https://api.github.com/users/MilesMcBain/starred{/owner}{/repo}", 125 | "subscriptions_url": "https://api.github.com/users/MilesMcBain/subscriptions", 126 | "organizations_url": "https://api.github.com/users/MilesMcBain/orgs", 127 | "repos_url": "https://api.github.com/users/MilesMcBain/repos", 128 | "events_url": "https://api.github.com/users/MilesMcBain/events{/privacy}", 129 | "received_events_url": "https://api.github.com/users/MilesMcBain/received_events", 130 | "type": "User", 131 | "site_admin": false 132 | }, 133 | "labels": [], 134 | "state": "open", 135 | "locked": false, 136 | "assignee": {}, 137 | "assignees": [], 138 | "milestone": {}, 139 | "comments": 0, 140 | "created_at": "2022-08-22T12:19:01Z", 141 | "updated_at": "2022-08-22T12:19:01Z", 142 | "closed_at": {}, 143 | "author_association": "OWNER", 144 | "active_lock_reason": {}, 145 | "body": "\nIt would be cool to have the jump and expand navigation options work for regular\nissue references. eg: `#123`. In this case the repo context would have to be\nretrieved from the yaml. Not too hard to do.", 146 | "reactions": { 147 | "url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/13/reactions", 148 | "total_count": 0, 149 | "+1": 0, 150 | "-1": 0, 151 | "laugh": 0, 152 | "hooray": 0, 153 | "confused": 0, 154 | "heart": 0, 155 | "rocket": 0, 156 | "eyes": 0 157 | }, 158 | "timeline_url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/13/timeline", 159 | "performed_via_github_app": {}, 160 | "state_reason": {}, 161 | "score": 1 162 | }, 163 | ``` 164 | 165 | an isssue lookes like this: 166 | 167 | ``` 168 | { 169 | "url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/11", 170 | "repository_url": "https://api.github.com/repos/MilesMcBain/rmdgh", 171 | "labels_url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/11/labels{/name}", 172 | "comments_url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/11/comments", 173 | "events_url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/11/events", 174 | "html_url": "https://github.com/MilesMcBain/rmdgh/issues/11", 175 | "id": 1345457004, 176 | "node_id": "I_kwDOHZOUYc5QMgts", 177 | "number": 11, 178 | "title": "Ability to add labels in Rmd and filter on them in search", 179 | "user": { 180 | "login": "MilesMcBain", 181 | "id": 9996346, 182 | "node_id": "MDQ6VXNlcjk5OTYzNDY=", 183 | "avatar_url": "https://avatars.githubusercontent.com/u/9996346?v=4", 184 | "gravatar_id": "", 185 | "url": "https://api.github.com/users/MilesMcBain", 186 | "html_url": "https://github.com/MilesMcBain", 187 | "followers_url": "https://api.github.com/users/MilesMcBain/followers", 188 | "following_url": "https://api.github.com/users/MilesMcBain/following{/other_user}", 189 | "gists_url": "https://api.github.com/users/MilesMcBain/gists{/gist_id}", 190 | "starred_url": "https://api.github.com/users/MilesMcBain/starred{/owner}{/repo}", 191 | "subscriptions_url": "https://api.github.com/users/MilesMcBain/subscriptions", 192 | "organizations_url": "https://api.github.com/users/MilesMcBain/orgs", 193 | "repos_url": "https://api.github.com/users/MilesMcBain/repos", 194 | "events_url": "https://api.github.com/users/MilesMcBain/events{/privacy}", 195 | "received_events_url": "https://api.github.com/users/MilesMcBain/received_events", 196 | "type": "User", 197 | "site_admin": false 198 | }, 199 | "labels": [], 200 | "state": "open", 201 | "locked": false, 202 | "assignee": {}, 203 | "assignees": [], 204 | "milestone": {}, 205 | "comments": 0, 206 | "created_at": "2022-08-21T11:11:36Z", 207 | "updated_at": "2022-08-21T11:47:55Z", 208 | "closed_at": {}, 209 | "author_association": "OWNER", 210 | "active_lock_reason": {}, 211 | "body": {}, 212 | "closed_by": {}, 213 | "reactions": { 214 | "url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/11/reactions", 215 | "total_count": 0, 216 | "+1": 0, 217 | "-1": 0, 218 | "laugh": 0, 219 | "hooray": 0, 220 | "confused": 0, 221 | "heart": 0, 222 | "rocket": 0, 223 | "eyes": 0 224 | }, 225 | "timeline_url": "https://api.github.com/repos/MilesMcBain/rmdgh/issues/11/timeline", 226 | "performed_via_github_app": {}, 227 | "state_reason": {} 228 | } 229 | 230 | ``` 231 | -------------------------------------------------------------------------------- /inst/rmarkdown/templates/github_issue/skeleton/skeleton.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Your issue title" 3 | author: "`r rmdgh::get_gh_user()`" 4 | output: 5 | rmdgh::github_issue: 6 | repo: milesmcbain/rmdgh 7 | labels: ~ 8 | action: create 9 | draft: yes 10 | --- 11 | 12 | ```{r setup, include=FALSE} 13 | # also useful in yaml - number: of an existing issue to make a new comment in thread 14 | knitr::opts_chunk$set(echo = TRUE, eval = TRUE, error = TRUE) 15 | ``` 16 | 17 | Here is my reprex: 18 | 19 | ```{reprex} 20 | library(rmdgh) 21 | rmdgh:::repo_issues("doesnt/exist") 22 | ``` 23 | -------------------------------------------------------------------------------- /inst/rmarkdown/templates/github_issue/template.yaml: -------------------------------------------------------------------------------- 1 | name: github_issue 2 | description: > 3 | A GitHub issue 4 | create_dir: FALSE 5 | -------------------------------------------------------------------------------- /man/draft_issue.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/draft_issue.R 3 | \name{draft_issue} 4 | \alias{draft_issue} 5 | \title{Create a GitHub issue from Rmarkdown} 6 | \usage{ 7 | draft_issue( 8 | filename = "issue.Rmd", 9 | path = getOption("rmdgh_issue_draft_path", get_pkg_user_dir()), 10 | overwrite = TRUE 11 | ) 12 | } 13 | \arguments{ 14 | \item{filename}{the Rmarkdown file name to contain your issue} 15 | 16 | \item{path}{the path to create the rmd issue in. Defaults to 17 | \code{tools::R_user_dir("rmdgh")}. Issues created in the default directory will be 18 | automatically cleaned up. Default can be changed with option \code{rmd_gh_issue_draft_path}.} 19 | 20 | \item{overwrite}{whether or not to overwrite an existing issue with the same filename (defaults to TRUE).} 21 | } 22 | \description{ 23 | An Rmarkdown document is created an opened that can add or comment on 24 | issues/PRs when rendered with \code{rmarkdown::render()}, the 'knit' button in 25 | RStudio, or the 'Knit Rmd' command in VSCode. 26 | } 27 | \details{ 28 | By default issues are created in a temporary dir, but this can be overidden 29 | and they will be created in the current directory. 30 | } 31 | -------------------------------------------------------------------------------- /man/get_gh_user.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/search.R 3 | \name{get_gh_user} 4 | \alias{get_gh_user} 5 | \title{Get the github user using local git email config.} 6 | \usage{ 7 | get_gh_user() 8 | } 9 | \description{ 10 | Get the github user using local git email config. 11 | } 12 | -------------------------------------------------------------------------------- /man/gh_thread.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/gh_thread.R 3 | \name{gh_thread} 4 | \alias{gh_thread} 5 | \title{Open an issue thread} 6 | \usage{ 7 | gh_thread(thread = NULL) 8 | } 9 | \arguments{ 10 | \item{thread}{text representing a thread to open, or nothing to try the clipboard. See details} 11 | } 12 | \description{ 13 | Three types of arugments are supported for opening an issue thread: 14 | \itemize{ 15 | \item no argument (NULL), an attempt is made to read a url for a thread from the clipboard 16 | \item numeric argument, an attempt is made to open an issue with this number in 17 | the repo corresponding to the current local repository 18 | \item A shorthand syntax: \code{"milesmcbain/capsule#12"} or \code{"capsule#12"} 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /man/github_issue.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/github_issue.R 3 | \name{github_issue} 4 | \alias{github_issue} 5 | \title{A github issue Rmarkdown format} 6 | \usage{ 7 | github_issue( 8 | repo = NULL, 9 | number = NULL, 10 | labels = NULL, 11 | action = "create", 12 | draft = TRUE, 13 | close_with_comment = FALSE, 14 | fig_width = 7, 15 | fig_height = 5, 16 | dev = "png", 17 | df_print = "default", 18 | math_method = "default", 19 | wrap = "preserve" 20 | ) 21 | } 22 | \arguments{ 23 | \item{repo}{the repository create the issue on e.g. "milesmcbain/capsule", or 24 | "capsule". Name will be resolved against locally insalled packages and CRAN in 25 | the later case.} 26 | 27 | \item{number}{the issue number in the repository if performing a comment or update action} 28 | 29 | \item{labels}{the labels to set for the issue if performing create or update action} 30 | 31 | \item{action}{the type of action to perform: "create", "update", "comment". Only "comment" is valid for PRs.} 32 | 33 | \item{draft}{if TRUE the action is not performed after rendering the document to 34 | GitHub markdown - set to TRUE initially to give you a chance to preview 35 | markdown output.} 36 | 37 | \item{close_with_comment}{if TRUE close the issue thread with comment action (assuming you have appropriate repo permissions).} 38 | 39 | \item{fig_width}{passed to image dimension passed to \code{\link[rmarkdown:github_document]{rmarkdown::github_document()}}} 40 | 41 | \item{fig_height}{image dimension passed to image dimension passed to \code{\link[rmarkdown:github_document]{rmarkdown::github_document()}}} 42 | 43 | \item{dev}{graphics device argument passed to \code{\link[rmarkdown:github_document]{rmarkdown::github_document()}}} 44 | 45 | \item{df_print}{data.frame print method passed to \code{\link[rmarkdown:github_document]{rmarkdown::github_document()}}} 46 | 47 | \item{math_method}{latex style math expression rendering method passed \code{\link[rmarkdown:github_document]{rmarkdown::github_document()}}} 48 | 49 | \item{wrap}{argument passed to pandoc. Controls use of line breaks. One of 50 | "auto", "preserve" or "none". "preserve" will mostly not interfere with your 51 | text formatting.} 52 | } 53 | \description{ 54 | A wraper for \code{\link[rmarkdown:github_document]{rmarkdown::github_document()}} that can peform actions on GitHub 55 | when rendering relating to issues and PRs including: 56 | \itemize{ 57 | \item create an issue 58 | \item update an issue title, body, and labels 59 | \item comment on an issue 60 | \item close an issue with a comment 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /man/issues.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/search.R 3 | \name{repo_issues} 4 | \alias{repo_issues} 5 | \alias{repo_prs} 6 | \alias{my_issues} 7 | \alias{issues_with_me} 8 | \alias{my_prs} 9 | \alias{prs_with_me} 10 | \alias{prs_for_me} 11 | \alias{issues_for_me} 12 | \alias{gh_for_me} 13 | \alias{issues} 14 | \title{Search Issues and PRs and present results in Rmarkdown} 15 | \usage{ 16 | repo_issues( 17 | repos = get_repo_remote(), 18 | query_description = 19 | glue::glue("repository issues for {paste(repos, collapse = \\" \\")}"), 20 | ... 21 | ) 22 | 23 | repo_prs( 24 | repos = get_repo_remote(), 25 | query_description = glue::glue("repository PRs for {paste(repos, collapse = \\" \\")}"), 26 | ... 27 | ) 28 | 29 | my_issues( 30 | author = get_gh_user(), 31 | query_description = glue::glue("{paste(author, collapse = \\" \\")} issues"), 32 | ... 33 | ) 34 | 35 | issues_with_me( 36 | involves = get_gh_user(), 37 | query_description = glue::glue("issues with {paste0(involves)}"), 38 | ... 39 | ) 40 | 41 | my_prs( 42 | author = get_gh_user(), 43 | type = "pr", 44 | query_description = glue::glue("PRs by {paste0(author)}"), 45 | ... 46 | ) 47 | 48 | prs_with_me( 49 | involves = get_gh_user(), 50 | type = "pr", 51 | query_description = glue::glue("PRs with {paste0(involves)}"), 52 | ... 53 | ) 54 | 55 | prs_for_me( 56 | user = get_gh_user(), 57 | type = "pr", 58 | query_description = glue::glue("PRs for {paste0(user)}"), 59 | extra_params = glue::glue("user:{user}"), 60 | ... 61 | ) 62 | 63 | issues_for_me( 64 | user = get_gh_user(), 65 | type = "issue", 66 | query_description = glue::glue("Issues for {paste0(user)}"), 67 | extra_params = glue::glue("user:{user}"), 68 | ... 69 | ) 70 | 71 | gh_for_me( 72 | user = get_gh_user(), 73 | type = NULL, 74 | query_description = glue::glue("Issues and PRs for {paste0(user)}"), 75 | extra_params = glue::glue("user:{user}"), 76 | ... 77 | ) 78 | 79 | issues( 80 | repos = NULL, 81 | search_query = NULL, 82 | type = "issue", 83 | search_in = c("title", "body"), 84 | author = NULL, 85 | involves = NULL, 86 | is_open = TRUE, 87 | label = NULL, 88 | query_description = NULL, 89 | order = "desc", 90 | extra_params = NULL 91 | ) 92 | } 93 | \arguments{ 94 | \item{repos}{a character vector issue repositories to get issues from. "owner/repo" 95 | and "repo" are allowed, with the repo resolved against 96 | installed R packages and CRAN in the latter case.} 97 | 98 | \item{query_description}{describe this query (appears in Rmarkdown results). Useful for building higher level functionality.} 99 | 100 | \item{...}{arguments passed to \code{\link[=issues]{issues()}}} 101 | 102 | \item{author}{a character vector of issue authors to return issues by.} 103 | 104 | \item{involves}{a character vector of users to find in issue/PR threads, filtering returned results.} 105 | 106 | \item{type}{\code{"issue"} or \code{"pr"} or NULL for both kinds.} 107 | 108 | \item{user}{the user to return issues or PRs for} 109 | 110 | \item{extra_params}{a character vector of extra query parameters that are passed verbatim to the github API. This take the form \code{"key:value"} e.g. \code{org:reditorsupport}.} 111 | 112 | \item{search_query}{a character string to search for in issue titles and bodies.} 113 | 114 | \item{search_in}{where to apply \verb{search query}. Any combination of \code{"title"} or \code{"body"}. The default is \code{c("title", "body")} for both.} 115 | 116 | \item{is_open}{TRUE for open issues, FALSE for closed, NULL for both.} 117 | 118 | \item{label}{a character vector of issue labels to filter reutrned results.} 119 | 120 | \item{order}{\code{"asc"} or \code{"desc"} - the ordering of search results by last update date.} 121 | } 122 | \description{ 123 | \code{issues()} and its user-friendly wrappers are designed to allow you quick 124 | access to lists of issues and PRs that you can use as a jumping off point for 125 | exploring Rmarkdown issue threads. 126 | } 127 | \details{ 128 | Results are paged according to \code{getOption('issue_search_results_per_page')}. 129 | 130 | Navigate between pages with \code{\link[=issue_search_results_forward]{issue_search_results_forward()}} and \code{\link[=issue_search_results_backward]{issue_search_results_backward()}}. 131 | 132 | Previw an issue thread inline with search results with \code{\link[=issue_search_results_expand]{issue_search_results_expand()}}. 133 | 134 | Use \code{\link[=jump_to_issue_thread]{jump_to_issue_thread()}} to jump to an Rmarkdown thread based on the 135 | cursor position or \code{\link[=jump_to_issue_webpage]{jump_to_issue_webpage()}} to jump to the web. 136 | } 137 | \section{Functions}{ 138 | \itemize{ 139 | \item \code{repo_issues()}: issues for the local repository 140 | 141 | \item \code{repo_prs()}: PRs for the local repository 142 | 143 | \item \code{my_issues()}: issues authored by you 144 | 145 | \item \code{issues_with_me()}: issues referring to you 146 | 147 | \item \code{my_prs()}: PRs by you 148 | 149 | \item \code{prs_with_me()}: PRs referring to you 150 | 151 | \item \code{prs_for_me()}: PRs in repositories you own 152 | 153 | \item \code{issues_for_me()}: issues in repositories you own 154 | 155 | \item \code{gh_for_me()}: all issues and PRs in repositories you own. 156 | 157 | }} 158 | -------------------------------------------------------------------------------- /man/rmdgh-package.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/rmdgh-package.R 3 | \docType{package} 4 | \name{rmdgh-package} 5 | \alias{rmdgh} 6 | \alias{rmdgh-package} 7 | \title{rmdgh: R to GitHub productivity via Rmarkdown} 8 | \description{ 9 | Browse and interact with GitHub via an Rmarkdown document interface. 10 | } 11 | \author{ 12 | \strong{Maintainer}: Miles McBain \email{miles.mcbain@gmail.com} (\href{https://orcid.org/0000-0003-2865-2548}{ORCID}) 13 | 14 | } 15 | \keyword{internal} 16 | -------------------------------------------------------------------------------- /man/save_issue.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/save_issue.R 3 | \name{save_issue} 4 | \alias{save_issue} 5 | \title{Save an issue to the current working directory} 6 | \usage{ 7 | save_issue( 8 | filename = NULL, 9 | folder = getOption("rmdgh_issue_location", "./issues") 10 | ) 11 | } 12 | \arguments{ 13 | \item{filename}{the name to give the issue. Defaulted from issue repo and number in yaml.} 14 | 15 | \item{folder}{where to store the issue. The default is "./issues".} 16 | } 17 | \description{ 18 | A helper for collecting issue documents locally to worked on at a later time. 19 | } 20 | \details{ 21 | \code{folder} will be \code{./issues} by default but is configurable in option 22 | \code{rmdgh_issue_location}. If it does not exist, it is created and added to the 23 | \code{.Rbuildignore}. 24 | } 25 | -------------------------------------------------------------------------------- /tests/testthat.R: -------------------------------------------------------------------------------- 1 | # This file is part of the standard setup for testthat. 2 | # It is recommended that you do not modify it. 3 | # 4 | # Where should you do additional test configuration? 5 | # Learn more about the roles of various files in: 6 | # * https://r-pkgs.org/tests.html 7 | # * https://testthat.r-lib.org/reference/test_package.html#special-files 8 | 9 | library(testthat) 10 | library(rmdgh) 11 | 12 | test_check("rmdgh") 13 | -------------------------------------------------------------------------------- /tests/testthat/test-forward_match_shortcode.R: -------------------------------------------------------------------------------- 1 | test_that("forward match shortcode and hashref", { 2 | 3 | no_match <- "something else" 4 | 5 | match <- "some text `gh milesmcbain/datapasta#33` more text" 6 | 7 | two_match <- "some text `gh milesmcbain/datapasta#33` more text `gh milesmcbain/atcursor#1` yeah" 8 | 9 | expect_null( 10 | forward_match_shortcode(no_match, 4), 11 | ) 12 | 13 | expect_equal( 14 | forward_match_shortcode(two_match, 2), 15 | structure( 16 | "`gh milesmcbain/datapasta#33`", 17 | class = "shortcode" 18 | ) 19 | ) 20 | 21 | expect_equal( 22 | forward_match_shortcode(two_match, 40), 23 | structure( 24 | "`gh milesmcbain/atcursor#1`", 25 | class = "shortcode" 26 | ) 27 | ) 28 | 29 | expect_null( 30 | forward_match_shortcode(two_match, 78), 31 | ) 32 | 33 | expect_equal( 34 | forward_match_shortcode(match, 11), 35 | structure( 36 | "`gh milesmcbain/datapasta#33`", 37 | class = "shortcode" 38 | ) 39 | ) 40 | 41 | numbers <- "- Need to look at faceting section `gh hadley/r4ds#1035` whole game :soccer:" 42 | 43 | expect_equal( 44 | forward_match_shortcode(numbers, 10), 45 | structure( 46 | "`gh hadley/r4ds#1035`", 47 | class = "shortcode" 48 | ) 49 | ) 50 | 51 | hash_line <- "this is a line #33" 52 | 53 | expect_equal( 54 | forward_match_hashref(hash_line, 5), 55 | structure( 56 | "#33", 57 | class = "hashref" 58 | ) 59 | ) 60 | 61 | }) 62 | -------------------------------------------------------------------------------- /tests/testthat/test-utils.R: -------------------------------------------------------------------------------- 1 | test_that("utils work", { 2 | expect_true( 3 | is_gh_url( 4 | "https://github.com/milesmcbain/capsule/issues/10" 5 | ) 6 | ) 7 | expect_false( 8 | is_gh_url( 9 | "htptjpoasjdfjiojapsfdj1238903450.com" 10 | ) 11 | ) 12 | expect_false( 13 | is_gh_url( 14 | "htptjpoasjdfjiohttps://github.comjapsfdj1238903450.com" 15 | ) 16 | ) 17 | 18 | gh_issue_info <- 19 | extract_issue_info_from_gh_url( 20 | "https://github.com/milesmcbain/capsule/issues/10" 21 | ) 22 | 23 | expect_equal( 24 | gh_issue_info$repo, "milesmcbain/capsule" 25 | ) 26 | 27 | expect_equal( 28 | gh_issue_info$number, "10" 29 | ) 30 | }) 31 | --------------------------------------------------------------------------------