working multi-account support and parsing of links

This commit is contained in:
Nathan Smith 2024-01-04 21:38:43 +00:00
parent f2feb5d897
commit 5efda44294
7 changed files with 306 additions and 113 deletions

3
.prettierrc Normal file
View file

@ -0,0 +1,3 @@
{
"printWidth": 120
}

View file

@ -41,25 +41,12 @@ export async function githubRequest(config: RequestUrlParam, token?: string) {
}
}
export async function getIssue(
org: string,
repo: string,
issue: number,
token?: string
) {
const result = await githubRequest(
{ url: `${baseApi}/repos/${org}/${repo}/issues/${issue}` },
token
);
export async function getIssue(org: string, repo: string, issue: number, token?: string) {
const result = await githubRequest({ url: `${baseApi}/repos/${org}/${repo}/issues/${issue}` }, token);
return result.json as RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
}
export async function getPullRequest(
org: string,
repo: string,
pr: number,
token?: string
) {
export async function getPullRequest(org: string, repo: string, pr: number, token?: string) {
const result = await githubRequest(
{
url: `${baseApi}/repos/${org}/${repo}/pulls/${pr}`,
@ -69,13 +56,7 @@ export async function getPullRequest(
return result.json as RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
}
export async function getCode(
org: string,
repo: string,
path: string,
branch: string,
token?: string
) {
export async function getCode(org: string, repo: string, path: string, branch: string, token?: string) {
const result = await githubRequest(
{
url: `${baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`,
@ -99,6 +80,7 @@ export function parseUrl(urlString: string): ParsedUrl {
const parsedUrl: ParsedUrl = { url: urlString, host: url.hostname };
const urlParts = url.pathname.split("/");
console.log(urlParts);
if (urlParts.length >= 4) {
switch (urlParts[3].toLowerCase()) {
case "issues":
@ -141,18 +123,14 @@ export function parseUrl(urlString: string): ParsedUrl {
parsedUrl.org = urlParts[1];
}
console.log(parsedUrl);
return parsedUrl;
}
async function doFetch(
url: RequestInfo | URL,
options?: RequestInit | undefined
): Promise<Response> {
async function doFetch(url: RequestInfo | URL, options?: RequestInit | undefined): Promise<Response> {
// Octokit always uses a url + options, not a Request object
if (typeof url !== "string") {
throw new Error(
"Something has gone horribly wrong and fetch has received unexpected arguments."
);
throw new Error("Something has gone horribly wrong and fetch has received unexpected arguments.");
}
if (options === undefined) {
throw new Error("No options given to fetch.");
@ -177,22 +155,12 @@ async function doFetch(
json: () => Promise.resolve(result.json),
} as Omit<
Response,
| "ok"
| "body"
| "statusText"
| "redirected"
| "type"
| "clone"
| "bodyUsed"
| "blob"
| "formData"
"ok" | "body" | "statusText" | "redirected" | "type" | "clone" | "bodyUsed" | "blob" | "formData"
>;
return partialResult as Response;
}
function getHeaders(
headers: HeadersInit | undefined
): Record<string, string> | undefined {
function getHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
@ -204,16 +172,12 @@ function getHeaders(
return result;
}
if (Array.isArray(headers)) {
throw new Error(
"Got array headers, we don't know what to do with this yet."
);
throw new Error("Got array headers, we don't know what to do with this yet.");
}
return headers;
}
function getBody(
body: BodyInit | null | undefined
): string | ArrayBuffer | undefined {
function getBody(body: BodyInit | null | undefined): string | ArrayBuffer | undefined {
if (!body) {
return undefined;
}

64
src/inline.ts Normal file
View file

@ -0,0 +1,64 @@
import { MarkdownPostProcessorContext, setIcon } from "obsidian";
import { getIssue, getPullRequest, parseUrl } from "./github";
import { PluginSettings } from "./plugin";
// import { PluginSettings } from "./plugin";
export async function InlineRenderer(el: HTMLElement, ctx: MarkdownPostProcessorContext) {
const githubLinks = el.querySelectorAll<HTMLAnchorElement>(`a.external-link[href^="https://github.com"]`);
for (const anchor of Array.from(githubLinks)) {
const url = anchor.href;
const parsedUrl = parseUrl(url);
let token: string | undefined;
// Try and find the matching token
if (parsedUrl.org) {
let account = PluginSettings.accounts.find((acc) => acc.orgs.some((org) => org === parsedUrl.org));
// Fall back to default token if available
if (!account && PluginSettings.defaultAccount) {
account = PluginSettings.accounts.find((acc) => acc.id === PluginSettings.defaultAccount);
}
token = account?.token;
}
const container = createEl("a", { cls: "gh-link-inline", href: url });
const icon = container.createSpan({ cls: "gh-link-inline-icon" });
setIcon(icon, "github"); // TODO: Set correct icon
if (parsedUrl.repo) {
container.createSpan({
cls: "gh-link-inline-repo",
text: parsedUrl.repo,
});
} else if (parsedUrl.org) {
container.createSpan({
cls: "gh-link-inline-org",
text: parsedUrl.org,
});
}
if (parsedUrl.repo && parsedUrl.org) {
if (parsedUrl.issue !== undefined) {
const issue = await getIssue(parsedUrl.org, parsedUrl.repo, parsedUrl.issue, token);
if (issue.title) {
setIcon(icon, "square-dot");
container.createSpan({
cls: "gh-link-inline-issue-title",
text: issue.title,
});
}
}
if (parsedUrl.pr !== undefined) {
const pull = await getPullRequest(parsedUrl.org, parsedUrl.repo, parsedUrl.pr, token);
if (pull.title) {
setIcon(icon, "git-pull-request-arrow");
container.createSpan({
cls: "gh-link-inline-pr-title",
text: pull.title,
});
}
}
// TODO: add support for other stuff here
}
anchor.replaceWith(container);
}
}

View file

@ -1,25 +1,21 @@
import {
GithubLinkPluginSettings,
GithubLinkPluginSettingsTab,
} from "./settings";
import { GithubLinkPluginSettings, GithubLinkPluginSettingsTab } from "./settings";
import { InlineRenderer } from "./inline";
import { Plugin } from "obsidian";
import { getIssue } from "./github";
export let PluginSettings: GithubLinkPluginSettings = { accounts: [] };
export class GithubLinkPlugin extends Plugin {
public settings: GithubLinkPluginSettings = {};
async onload() {
this.settings = Object.assign({}, await this.loadData());
PluginSettings = Object.assign({}, await this.loadData());
this.addSettingTab(new GithubLinkPluginSettingsTab(this.app, this));
this.registerMarkdownPostProcessor(InlineRenderer);
this.addCommand({
id: "get-issue",
name: "Get GitHub issue",
callback: async () => {
const result = await getIssue(
"nathonius",
"obsidian-trello",
4
);
const result = await getIssue("nathonius", "obsidian-trello", 4);
console.log(result);
},
});

View file

@ -1,16 +1,26 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { App, PluginSettingTab, Setting } from "obsidian";
import { GithubLinkPlugin, PluginSettings } from "./plugin";
import { AuthModal } from "./auth-modal";
import { GithubLinkPlugin } from "./plugin";
import { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import { auth } from "./github";
export interface GithubAccount {
id: string;
name: string;
orgs: string[];
token: string;
}
export interface GithubLinkPluginSettings {
token?: string;
accounts: GithubAccount[];
defaultAccount?: string;
}
export class GithubLinkPluginSettingsTab extends PluginSettingTab {
authModal: AuthModal | null = null;
newAccount: GithubAccount | null = null;
constructor(public app: App, private readonly plugin: GithubLinkPlugin) {
super(app, plugin);
@ -21,41 +31,175 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
containerEl.empty();
containerEl.createEl("h2", { text: "GitHub authentication" });
containerEl.createEl(
"p",
"No authentication is required to reference public repositories. Providing a token allows referencing private repos, but the token is stored in plain text."
);
containerEl.createEl("p", {
text: "No authentication is required to reference public repositories. Providing a token allows referencing private repos, but the token is stored in plain text. You can create multiple accounts for multiple tokens.",
});
new Setting(containerEl)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended)."
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(this.generateToken.bind(this));
})
.addText((text) => {
text.setPlaceholder("Personal Access Token");
if (this.plugin.settings.token) {
text.setValue(this.plugin.settings.token);
}
new Setting(containerEl).setName("Add account").addButton((button) => {
button.setButtonText("");
button.setIcon("plus");
button.setTooltip("Add Account");
button.onClick(() => {
this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "" };
this.display();
});
});
new Setting(containerEl)
.setName("Default account")
.setDesc("The account that will be used if no other users or organizations match.")
.addDropdown((dropdown) => {
const options = PluginSettings.accounts.reduce<Record<string, string>>((acc, account) => {
acc[account.id] = account.name;
return acc;
}, {});
dropdown.addOptions(options);
dropdown.setValue(PluginSettings.defaultAccount ?? "");
dropdown.onChange(async (value) => {
console.log(value);
const selectedAccount = PluginSettings.accounts.find((acc) => acc.id === value);
if (selectedAccount) {
PluginSettings.defaultAccount = selectedAccount.id;
await this.saveSettings();
}
});
});
// TODO: Combine the new account and existing account rendering to reduce duplication
if (this.newAccount !== null) {
const accountContainer = containerEl.createDiv();
const header = accountContainer.createEl("h3", { text: "New account" });
new Setting(accountContainer)
.setName("Account name")
.setDesc("Required.")
.addText((text) => {
text.setValue(this.newAccount!.name);
text.onChange((value) => {
this.newAccount!.name = value;
header.setText(value ?? "New account");
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(() => {
this.newAccount = null;
this.display();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc(
"A comma separated list of the GitHub organizations and users this account should be used for. Optional."
)
.addTextArea((text) => {
text.setValue(this.newAccount!.orgs.join(", "));
text.onChange((value) => {
this.newAccount!.orgs = value.split(",");
});
});
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required."
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const authResult = await auth(this.tokenVerification.bind(this))({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
this.newAccount!.token = authResult.token;
this.display();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(this.newAccount!.token);
text.onChange((value) => {
this.newAccount!.token = value;
});
});
new Setting(accountContainer).addButton((button) => {
button.setTooltip("Save account");
button.setIcon("save");
button.onClick(async () => {
if (!this.newAccount || !this.newAccount.name || !this.newAccount.token) {
return;
}
PluginSettings.accounts.unshift(this.newAccount);
await this.saveSettings();
this.newAccount = null;
this.display();
});
});
}
for (const account of PluginSettings.accounts) {
const accountContainer = containerEl.createDiv();
accountContainer.createEl("h3", { text: account.name });
new Setting(accountContainer)
.setName("Account name")
.addText((text) => {
text.setValue(account.name);
text.onChange((value) => {
account.name = value;
this.saveSettings();
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(async () => {
PluginSettings.accounts.remove(account);
await this.saveSettings();
this.display();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc(
"A comma separated list of the GitHub organizations and users this account should be used for."
)
.addTextArea((text) => {
text.setValue(account.orgs.join(", "));
text.onChange((value) => {
account.orgs = value.split(",").map((org) => org.trim());
this.saveSettings();
});
});
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens)."
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const authResult = await auth(this.tokenVerification.bind(this))({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
account.token = authResult.token;
await this.saveSettings();
this.display();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(account.token);
text.onChange((value) => {
account.token = value;
this.saveSettings();
});
});
}
}
private saveSettings() {
return this.plugin.saveData(this.plugin.settings);
}
private async generateToken() {
const authResult = await auth(this.tokenVerification.bind(this))({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
this.plugin.settings.token = authResult.token;
await this.saveSettings();
this.display();
return this.plugin.saveData(PluginSettings);
}
private tokenVerification(verification: Verification) {

View file

@ -14,3 +14,31 @@
.auth-code {
font-size: 3em;
}
.gh-link-inline {
display: flex;
background-color: green;
border-radius: 4px;
text-decoration: none;
color: inherit;
align-items: center;
}
.gh-link-inline-icon {
background-color: orange;
border-radius: 4px;
display: flex;
padding: 2px;
}
.gh-link-inline-repo {
background-color: blue;
padding: 0 4px;
}
.gh-link-inline-pr-title {
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
padding: 0 4px;
}

View file

@ -1,24 +1,18 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strict": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["**/*.ts"]
}