// ==UserScript==
// @name berx
// @namespace Taywee
// @description Codeberg extensions. Also works for other forgejo and gitea hosts.
// @match https://codeberg.org/*
// @version 1.4.7
// @author Taylor C. Richberger
// @homepageURL https://codeberg.org/Taywee/berx
// @license MPL-2.0
// @grant GM.setClipboard
// ==/UserScript==
(function () {
'use strict';
const settings_path = '/user/settings/applications';
const storage_key = 'berx-access-key';
function auth_header() {
const key = localStorage.getItem(storage_key);
if (!key) {
return null;
}
return `token ${key}`;
}
function setup_token_settings() {
const document_fragment = document.createDocumentFragment();
if (localStorage.getItem(storage_key) === null) {
const form = document_fragment.appendChild(document.createElement('form'));
form.classList.add('ui', 'form', 'ignore-dirty');
const field = form.appendChild(document.createElement('div'));
field.classList.add('field');
const label = field.appendChild(document.createElement('label'));
label.setAttribute('for', 'berx-token');
label.textContent = 'Token';
const input = field.appendChild(document.createElement('input'));
input.id = 'berx-token';
input.name = 'berx-token';
const flash_key = document.querySelector('.flash-info.flash-message p')?.textContent;
if (flash_key != null) {
input.value = flash_key;
}
const button = form.appendChild(document.createElement('button'));
button.classList.add('button', 'ui', 'green');
button.textContent = 'Submit';
button.type = 'button';
button.addEventListener('click', () => {
localStorage.setItem(storage_key, input.value);
setup_token_settings();
});
} else {
const right_float = document_fragment.appendChild(document.createElement('div'));
right_float.classList.add('right', 'floated', 'content');
const button = right_float.appendChild(document.createElement('button'));
button.type = 'button';
button.classList.add('ui', 'red', 'tiny', 'button', 'delete-button');
button.textContent = 'Delete';
const p = document_fragment.appendChild(document.createElement('p'));
p.textContent = 'An Access Token is set.';
button.addEventListener('click', () => {
localStorage.removeItem(storage_key);
setup_token_settings();
});
}
const token_item = document.getElementById('berx-token-item');
token_item?.replaceChildren(document_fragment);
}
if (window.location.pathname === settings_path) {
const user_setting_content = document.querySelector('.user-setting-content');
const header = document.createElement('h4');
header.classList.add('ui', 'top', 'attached', 'header');
header.textContent = 'berx Access Token';
const body = document.createElement('div');
body.classList.add('ui', 'attached', 'segment', 'bottom');
const key_list = body.appendChild(document.createElement('div'));
key_list.classList.add('ui', 'key', 'list');
const description = key_list.appendChild(document.createElement('div'));
description.classList.add('item');
description.textContent = 'To function, berx needs an Access Token with write:issue and write:repository.';
const token_item = key_list.appendChild(document.createElement('div'));
token_item.classList.add('item');
token_item.id = 'berx-token-item';
const document_fragment = document.createDocumentFragment();
document_fragment.appendChild(header);
document_fragment.appendChild(body);
user_setting_content?.children[0].before(document_fragment);
setup_token_settings();
}
const issue_regex = /^\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<index>\d+)$/;
const illegal = /[^A-Za-z0-9-]+/g;
const refs_heads = /^refs\/heads\//;
const trailing_hyphens = /-+$/;
const path = window.location.pathname;
/// If the value is undefined, return an empty array, otherwise return an array
/// with the single element.
function filter(value) {
if (value === undefined) {
return [];
} else {
return [value];
}
}
async function setup_issue_pr(groups, output) {
const info = message => {
output.appendChild(document.createElement('li')).textContent = message;
};
const key = auth_header();
if (!key) {
info(`API key is not set, redirecting to settings.`);
window.location.href = '/user/settings/applications';
return;
}
const authorization = key;
async function request(path, extras = {}) {
const response = await fetch(path, {
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
Accept: 'application/json'
},
...extras
});
if (!response.ok) {
throw new Error(`Fetch response had error: ${response.status} ${response.statusText}`);
}
return await response.json();
}
function get(path) {
return request(path);
}
function post(path, body) {
return request(path, {
method: 'POST',
body: JSON.stringify(body)
});
}
function patch(path, body) {
return request(path, {
method: 'PATCH',
body: JSON.stringify(body)
});
}
const api_base = '/api/v1';
function getIssue(owner, repo, index) {
owner = encodeURIComponent(owner);
repo = encodeURIComponent(repo);
return get(`${api_base}/repos/${owner}/${repo}/issues/${index}`);
}
function getRepo(owner, repo) {
owner = encodeURIComponent(owner);
repo = encodeURIComponent(repo);
return get(`${api_base}/repos/${owner}/${repo}`);
}
function getBranch(owner, repo, branch) {
owner = encodeURIComponent(owner);
repo = encodeURIComponent(repo);
branch = encodeURIComponent(branch);
return get(`${api_base}/repos/${owner}/${repo}/branches/${branch}`);
}
function getPullRequests(owner, repo) {
owner = encodeURIComponent(owner);
repo = encodeURIComponent(repo);
return get(`${api_base}/repos/${owner}/${repo}/pulls?state=open`);
}
function createBranch(owner, repo, options) {
owner = encodeURIComponent(owner);
repo = encodeURIComponent(repo);
return post(`${api_base}/repos/${owner}/${repo}/branches`, options);
}
function createPullRequest(owner, repo, options) {
owner = encodeURIComponent(owner);
repo = encodeURIComponent(repo);
return post(`${api_base}/repos/${owner}/${repo}/pulls`, options);
}
function editIssue(owner, repo, index, options) {
owner = encodeURIComponent(owner);
repo = encodeURIComponent(repo);
return patch(`${api_base}/repos/${owner}/${repo}/issues/${index}`, options);
}
const owner = groups.owner;
const repo = groups.repo;
const index = parseInt(groups.index, 10);
const [issue, repository] = await Promise.all([getIssue(owner, repo, index), getRepo(owner, repo)]);
let branch;
if (issue.ref) {
branch = await getBranch(owner, repo, issue.ref.replace(refs_heads, ''));
} else {
const branch_title = issue?.title?.toLowerCase()?.replaceAll(illegal, '-')?.substring(0, 128)?.replace(trailing_hyphens, '');
const branch_name = branch_title ? `issues/${index}-${branch_title}` : `issues/${index}`;
try {
info(`Trying to find branch by name ${branch_name}`);
branch = await getBranch(owner, repo, branch_name);
} catch (_) {
branch = await createBranch(owner, repo, {
new_branch_name: branch_name,
old_ref_name: `heads/${repository.default_branch}`
});
}
info(`Assigning branch ${branch_name} to issue`);
await editIssue(owner, repo, index, {
ref: branch_name
});
}
info(`Finding open pull request for branch`);
const pull_requests = await getPullRequests(owner, repo);
let pull_request = pull_requests.find(each => each?.head?.ref === branch.name);
if (pull_request == null) {
info(`Creating pull request for branch`);
pull_request = await createPullRequest(owner, repo, {
assignees: issue.assignees?.map(user => user?.login)?.flatMap(filter),
base: repository.default_branch,
body: issue.body ? `${issue.body}\n\ncloses #${issue.number}` : `closes #${issue.number}`,
due_date: issue.due_date,
head: branch.name,
labels: issue.labels?.map(label => label.id).flatMap(filter),
milestone: issue.milestone?.id,
title: issue.title ? `WIP: ${issue.title}` : undefined
});
}
info(`Copying text to clipboard`);
GM.setClipboard(`git fetch origin; git switch ${branch.name}`);
const pr = `/${owner}/${repo}/pulls/${pull_request.number}`;
info(`Redirecting to ${pr}`);
window.location.href = pr;
}
const match = issue_regex.exec(path);
if (match !== null) {
const groups = match.groups;
const fragment = document.createDocumentFragment();
const button = fragment.appendChild(document.createElement('button'));
const output = fragment.appendChild(document.createElement('ul'));
const info = message => {
output.appendChild(document.createElement('li')).textContent = message;
};
button.classList.add('ui', 'green', 'icon', 'button');
button.textContent = 'Add branch and PR';
const select_branch = document.querySelector('.select-branch');
select_branch?.after(fragment);
button.addEventListener('click', async () => {
try {
await setup_issue_pr(groups, output);
} catch (error) {
info(`Error: ${error}. Trying one more time.`);
try {
await setup_issue_pr(groups, output);
} catch (error) {
info(`Error: ${error}. Trying again will work quite often.`);
}
}
});
}
})();