berx

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

  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. })();