berx

Codeberg extensions. Also works for other forgejo and gitea hosts.

  1. // ==UserScript==
  2. // @name berx
  3. // @namespace Taywee
  4. // @description Codeberg extensions. Also works for other forgejo and gitea hosts.
  5. // @match https://codeberg.org/*
  6. // @version 1.4.7
  7. // @author Taylor C. Richberger
  8. // @homepageURL https://codeberg.org/Taywee/berx
  9. // @license MPL-2.0
  10. // @grant GM.setClipboard
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. 'use strict';
  15.  
  16. const settings_path = '/user/settings/applications';
  17. const storage_key = 'berx-access-key';
  18. function auth_header() {
  19. const key = localStorage.getItem(storage_key);
  20. if (!key) {
  21. return null;
  22. }
  23. return `token ${key}`;
  24. }
  25. function setup_token_settings() {
  26. const document_fragment = document.createDocumentFragment();
  27. if (localStorage.getItem(storage_key) === null) {
  28. const form = document_fragment.appendChild(document.createElement('form'));
  29. form.classList.add('ui', 'form', 'ignore-dirty');
  30. const field = form.appendChild(document.createElement('div'));
  31. field.classList.add('field');
  32. const label = field.appendChild(document.createElement('label'));
  33. label.setAttribute('for', 'berx-token');
  34. label.textContent = 'Token';
  35. const input = field.appendChild(document.createElement('input'));
  36. input.id = 'berx-token';
  37. input.name = 'berx-token';
  38. const flash_key = document.querySelector('.flash-info.flash-message p')?.textContent;
  39. if (flash_key != null) {
  40. input.value = flash_key;
  41. }
  42. const button = form.appendChild(document.createElement('button'));
  43. button.classList.add('button', 'ui', 'green');
  44. button.textContent = 'Submit';
  45. button.type = 'button';
  46. button.addEventListener('click', () => {
  47. localStorage.setItem(storage_key, input.value);
  48. setup_token_settings();
  49. });
  50. } else {
  51. const right_float = document_fragment.appendChild(document.createElement('div'));
  52. right_float.classList.add('right', 'floated', 'content');
  53. const button = right_float.appendChild(document.createElement('button'));
  54. button.type = 'button';
  55. button.classList.add('ui', 'red', 'tiny', 'button', 'delete-button');
  56. button.textContent = 'Delete';
  57. const p = document_fragment.appendChild(document.createElement('p'));
  58. p.textContent = 'An Access Token is set.';
  59. button.addEventListener('click', () => {
  60. localStorage.removeItem(storage_key);
  61. setup_token_settings();
  62. });
  63. }
  64. const token_item = document.getElementById('berx-token-item');
  65. token_item?.replaceChildren(document_fragment);
  66. }
  67. if (window.location.pathname === settings_path) {
  68. const user_setting_content = document.querySelector('.user-setting-content');
  69. const header = document.createElement('h4');
  70. header.classList.add('ui', 'top', 'attached', 'header');
  71. header.textContent = 'berx Access Token';
  72. const body = document.createElement('div');
  73. body.classList.add('ui', 'attached', 'segment', 'bottom');
  74. const key_list = body.appendChild(document.createElement('div'));
  75. key_list.classList.add('ui', 'key', 'list');
  76. const description = key_list.appendChild(document.createElement('div'));
  77. description.classList.add('item');
  78. description.textContent = 'To function, berx needs an Access Token with write:issue and write:repository.';
  79. const token_item = key_list.appendChild(document.createElement('div'));
  80. token_item.classList.add('item');
  81. token_item.id = 'berx-token-item';
  82. const document_fragment = document.createDocumentFragment();
  83. document_fragment.appendChild(header);
  84. document_fragment.appendChild(body);
  85. user_setting_content?.children[0].before(document_fragment);
  86. setup_token_settings();
  87. }
  88.  
  89. const issue_regex = /^\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<index>\d+)$/;
  90. const illegal = /[^A-Za-z0-9-]+/g;
  91. const refs_heads = /^refs\/heads\//;
  92. const trailing_hyphens = /-+$/;
  93. const path = window.location.pathname;
  94.  
  95. /// If the value is undefined, return an empty array, otherwise return an array
  96. /// with the single element.
  97. function filter(value) {
  98. if (value === undefined) {
  99. return [];
  100. } else {
  101. return [value];
  102. }
  103. }
  104. async function setup_issue_pr(groups, output) {
  105. const info = message => {
  106. output.appendChild(document.createElement('li')).textContent = message;
  107. };
  108. const key = auth_header();
  109. if (!key) {
  110. info(`API key is not set, redirecting to settings.`);
  111. window.location.href = '/user/settings/applications';
  112. return;
  113. }
  114. const authorization = key;
  115. async function request(path, extras = {}) {
  116. const response = await fetch(path, {
  117. headers: {
  118. Authorization: authorization,
  119. 'Content-Type': 'application/json',
  120. Accept: 'application/json'
  121. },
  122. ...extras
  123. });
  124. if (!response.ok) {
  125. throw new Error(`Fetch response had error: ${response.status} ${response.statusText}`);
  126. }
  127. return await response.json();
  128. }
  129. function get(path) {
  130. return request(path);
  131. }
  132. function post(path, body) {
  133. return request(path, {
  134. method: 'POST',
  135. body: JSON.stringify(body)
  136. });
  137. }
  138. function patch(path, body) {
  139. return request(path, {
  140. method: 'PATCH',
  141. body: JSON.stringify(body)
  142. });
  143. }
  144. const api_base = '/api/v1';
  145. function getIssue(owner, repo, index) {
  146. owner = encodeURIComponent(owner);
  147. repo = encodeURIComponent(repo);
  148. return get(`${api_base}/repos/${owner}/${repo}/issues/${index}`);
  149. }
  150. function getRepo(owner, repo) {
  151. owner = encodeURIComponent(owner);
  152. repo = encodeURIComponent(repo);
  153. return get(`${api_base}/repos/${owner}/${repo}`);
  154. }
  155. function getBranch(owner, repo, branch) {
  156. owner = encodeURIComponent(owner);
  157. repo = encodeURIComponent(repo);
  158. branch = encodeURIComponent(branch);
  159. return get(`${api_base}/repos/${owner}/${repo}/branches/${branch}`);
  160. }
  161. function getPullRequests(owner, repo) {
  162. owner = encodeURIComponent(owner);
  163. repo = encodeURIComponent(repo);
  164. return get(`${api_base}/repos/${owner}/${repo}/pulls?state=open`);
  165. }
  166. function createBranch(owner, repo, options) {
  167. owner = encodeURIComponent(owner);
  168. repo = encodeURIComponent(repo);
  169. return post(`${api_base}/repos/${owner}/${repo}/branches`, options);
  170. }
  171. function createPullRequest(owner, repo, options) {
  172. owner = encodeURIComponent(owner);
  173. repo = encodeURIComponent(repo);
  174. return post(`${api_base}/repos/${owner}/${repo}/pulls`, options);
  175. }
  176. function editIssue(owner, repo, index, options) {
  177. owner = encodeURIComponent(owner);
  178. repo = encodeURIComponent(repo);
  179. return patch(`${api_base}/repos/${owner}/${repo}/issues/${index}`, options);
  180. }
  181. const owner = groups.owner;
  182. const repo = groups.repo;
  183. const index = parseInt(groups.index, 10);
  184. const [issue, repository] = await Promise.all([getIssue(owner, repo, index), getRepo(owner, repo)]);
  185. let branch;
  186. if (issue.ref) {
  187. branch = await getBranch(owner, repo, issue.ref.replace(refs_heads, ''));
  188. } else {
  189. const branch_title = issue?.title?.toLowerCase()?.replaceAll(illegal, '-')?.substring(0, 128)?.replace(trailing_hyphens, '');
  190. const branch_name = branch_title ? `issues/${index}-${branch_title}` : `issues/${index}`;
  191. try {
  192. info(`Trying to find branch by name ${branch_name}`);
  193. branch = await getBranch(owner, repo, branch_name);
  194. } catch (_) {
  195. branch = await createBranch(owner, repo, {
  196. new_branch_name: branch_name,
  197. old_ref_name: `heads/${repository.default_branch}`
  198. });
  199. }
  200. info(`Assigning branch ${branch_name} to issue`);
  201. await editIssue(owner, repo, index, {
  202. ref: branch_name
  203. });
  204. }
  205. info(`Finding open pull request for branch`);
  206. const pull_requests = await getPullRequests(owner, repo);
  207. let pull_request = pull_requests.find(each => each?.head?.ref === branch.name);
  208. if (pull_request == null) {
  209. info(`Creating pull request for branch`);
  210. pull_request = await createPullRequest(owner, repo, {
  211. assignees: issue.assignees?.map(user => user?.login)?.flatMap(filter),
  212. base: repository.default_branch,
  213. body: issue.body ? `${issue.body}\n\ncloses #${issue.number}` : `closes #${issue.number}`,
  214. due_date: issue.due_date,
  215. head: branch.name,
  216. labels: issue.labels?.map(label => label.id).flatMap(filter),
  217. milestone: issue.milestone?.id,
  218. title: issue.title ? `WIP: ${issue.title}` : undefined
  219. });
  220. }
  221. info(`Copying text to clipboard`);
  222. GM.setClipboard(`git fetch origin; git switch ${branch.name}`);
  223. const pr = `/${owner}/${repo}/pulls/${pull_request.number}`;
  224. info(`Redirecting to ${pr}`);
  225. window.location.href = pr;
  226. }
  227. const match = issue_regex.exec(path);
  228. if (match !== null) {
  229. const groups = match.groups;
  230. const fragment = document.createDocumentFragment();
  231. const button = fragment.appendChild(document.createElement('button'));
  232. const output = fragment.appendChild(document.createElement('ul'));
  233. const info = message => {
  234. output.appendChild(document.createElement('li')).textContent = message;
  235. };
  236. button.classList.add('ui', 'green', 'icon', 'button');
  237. button.textContent = 'Add branch and PR';
  238. const select_branch = document.querySelector('.select-branch');
  239. select_branch?.after(fragment);
  240. button.addEventListener('click', async () => {
  241. try {
  242. await setup_issue_pr(groups, output);
  243. } catch (error) {
  244. info(`Error: ${error}. Trying one more time.`);
  245. try {
  246. await setup_issue_pr(groups, output);
  247. } catch (error) {
  248. info(`Error: ${error}. Trying again will work quite often.`);
  249. }
  250. }
  251. });
  252. }
  253.  
  254. })();