feat: Basic query search support

Still a lot to do, but able to render a table with info from a search of issues
This commit is contained in:
Nathan Smith 2024-01-31 04:02:53 +00:00
parent 39ae421590
commit 69014fb27e
12 changed files with 4529 additions and 14 deletions

162
.better-commits.json Normal file
View file

@ -0,0 +1,162 @@
{
"check_status": true,
"commit_type": {
"enable": true,
"initial_value": "feat",
"infer_type_from_branch": true,
"append_emoji_to_label": true,
"append_emoji_to_commit": true,
"options": [
{
"value": "feat",
"label": "feat",
"hint": "A new feature",
"emoji": "✨",
"trailer": "Changelog: feature"
},
{
"value": "fix",
"label": "fix",
"hint": "A bug fix",
"emoji": "🐛",
"trailer": "Changelog: fix"
},
{
"value": "docs",
"label": "docs",
"hint": "Documentation only changes",
"emoji": "📚",
"trailer": "Changelog: documentation"
},
{
"value": "refactor",
"label": "refactor",
"hint": "A code change that neither fixes a bug nor adds a feature",
"emoji": "🔨",
"trailer": "Changelog: refactor"
},
{
"value": "release",
"label": "release",
"hint": "A code change to release a new version",
"emoji": "🚀",
"trailer": "Changelog: release"
},
{
"value": "test",
"label": "test",
"hint": "Adding missing tests or correcting existing tests",
"emoji": "🚨",
"trailer": "Changelog: test"
},
{
"value": "build",
"label": "build",
"hint": "Changes that affect the build system or external dependencies",
"emoji": "🚧",
"trailer": "Changelog: build"
},
{
"value": "ci",
"label": "ci",
"hint": "Changes to our CI configuration files and scripts",
"emoji": "🤖",
"trailer": "Changelog: ci"
},
{
"value": "chore",
"label": "chore",
"hint": "Other changes that do not modify src or test files",
"emoji": "🧹",
"trailer": "Changelog: chore"
},
{
"value": "",
"label": "none"
}
]
},
"commit_scope": {
"enable": false,
"custom_scope": false,
"initial_value": "app",
"options": [
{
"value": "app",
"label": "app"
},
{
"value": "shared",
"label": "shared"
},
{
"value": "server",
"label": "server"
},
{
"value": "tools",
"label": "tools"
},
{
"value": "",
"label": "none"
}
]
},
"check_ticket": {
"infer_ticket": true,
"confirm_ticket": true,
"add_to_title": true,
"append_hashtag": false,
"surround": "",
"title_position": "start"
},
"commit_title": {
"max_size": 70
},
"commit_body": {
"enable": true,
"required": false
},
"commit_footer": {
"enable": true,
"initial_value": [],
"options": ["closes", "trailer", "breaking-change", "deprecated", "custom"]
},
"breaking_change": {
"add_exclamation_to_title": true
},
"confirm_commit": true,
"print_commit_output": true,
"branch_pre_commands": [],
"branch_post_commands": [],
"worktree_pre_commands": [],
"worktree_post_commands": [],
"branch_user": {
"enable": true,
"required": false,
"separator": "/"
},
"branch_type": {
"enable": true,
"separator": "/"
},
"branch_version": {
"enable": false,
"required": false,
"separator": "/"
},
"branch_ticket": {
"enable": true,
"required": false,
"separator": "-"
},
"branch_description": {
"max_length": 70,
"separator": ""
},
"branch_action_default": "branch",
"branch_order": ["user", "version", "type", "ticket", "description"],
"enable_worktrees": true,
"overrides": {}
}

View file

@ -1,4 +1,4 @@
import type { CodeResponse, IssueResponse, PullResponse } from "./response";
import type { CodeResponse, IssueResponse, PullResponse, SearchIssueResponse, SearchRepoResponse } from "./response";
import type { RequestUrlParam } from "obsidian";
import { requestUrl } from "obsidian";
@ -48,8 +48,20 @@ async function getCode(org: string, repo: string, path: string, branch: string,
return result.json as CodeResponse;
}
async function searchRepos(query: string, token?: string): Promise<SearchRepoResponse> {
const result = await githubRequest({ url: `${baseApi}/search/repositories?q=${encodeURIComponent(query)}` }, token);
return result.json as SearchRepoResponse;
}
async function searchIssues(query: string, token?: string): Promise<SearchIssueResponse> {
const result = await githubRequest({ url: `${baseApi}/search/issues?q=${encodeURIComponent(query)}` }, token);
return result.json as SearchIssueResponse;
}
export const api = {
getIssue,
getPullRequest,
getCode,
searchIssues,
searchRepos,
};

View file

@ -1,4 +1,4 @@
import type { IssueResponse, PullResponse } from "./response";
import type { IssueResponse, PullResponse, SearchIssueResponse, SearchRepoResponse } from "./response";
class CacheEntry<T> {
constructor(
@ -14,6 +14,11 @@ class CacheEntry<T> {
}
}
class QueryCache {
public readonly issueCache: Record<string, CacheEntry<SearchIssueResponse>> = {};
public readonly repoCache: Record<string, CacheEntry<SearchRepoResponse>> = {};
}
class RepoCache {
public readonly issueCache: Record<number, CacheEntry<IssueResponse>> = {};
public readonly pullCache: Record<string, CacheEntry<PullResponse>> = {};
@ -25,6 +30,7 @@ class OrgCache {
export class Cache {
public readonly orgs: Record<string, OrgCache> = {};
public readonly queries = new QueryCache();
getIssue(org: string, repo: string, issue: number): IssueResponse | null {
const repoCache = this.getRepoCache(org, repo);
@ -60,6 +66,22 @@ export class Cache {
}
}
getIssueQuery(query: string): SearchIssueResponse | null {
return this.getCacheValue(this.queries.issueCache[query] ?? null);
}
setIssueQuery(query: string, result: SearchIssueResponse): void {
this.queries.issueCache[query] = new CacheEntry<SearchIssueResponse>(result);
}
getRepoQuery(query: string): SearchRepoResponse | null {
return this.getCacheValue(this.queries.repoCache[query] ?? null);
}
setRepoQuery(query: string, result: SearchRepoResponse): void {
this.queries.repoCache[query] = new CacheEntry<SearchRepoResponse>(result);
}
private getRepoCache(org: string, repo: string) {
let orgCache = this.orgs[org];
if (!orgCache) {

View file

@ -1,12 +1,12 @@
import type { IssueResponse, PullResponse } from "./response";
import type { IssueResponse, PullResponse, SearchIssueResponse, SearchRepoResponse } from "./response";
import { Cache } from "./cache";
import { api } from "./api";
import { PluginSettings } from "src/plugin";
import { api } from "./api";
const cache = new Cache();
function getToken(org: string): string | undefined {
function getToken(org?: string): string | undefined {
const account =
PluginSettings.accounts.find((acc) => acc.orgs.some((savedOrg) => savedOrg === org)) ??
PluginSettings.accounts.find((acc) => acc.id === PluginSettings.defaultAccount);
@ -34,3 +34,25 @@ export async function getPullRequest(org: string, repo: string, pullRequest: num
cache.setPullRequest(org, repo, response);
return response;
}
export async function searchIssues(query: string, org?: string): Promise<SearchIssueResponse> {
const cachedResponse = cache.getIssueQuery(query);
if (cachedResponse) {
return Promise.resolve(cachedResponse);
}
const response = await api.searchIssues(query, getToken(org));
cache.setIssueQuery(query, response);
return response;
}
export async function searchRepos(query: string, org?: string): Promise<SearchRepoResponse> {
const cachedResponse = cache.getRepoQuery(query);
if (cachedResponse) {
return Promise.resolve(cachedResponse);
}
const response = await api.searchRepos(query, getToken(org));
cache.setRepoQuery(query, response);
return response;
}

View file

@ -1,5 +1,15 @@
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
export enum IssueStatus {
Open = "open",
Closed = "closed",
Done = "done",
}
export type IssueResponse = RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
export type PullResponse = RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
export type CodeResponse = RestEndpointMethodTypes["repos"]["getContent"]["response"]["data"];
export type SearchRepoParams = RestEndpointMethodTypes["search"]["repos"]["parameters"];
export type SearchRepoResponse = RestEndpointMethodTypes["search"]["repos"]["response"]["data"];
export type SearchIssueParams = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["parameters"];
export type SearchIssueResponse = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"];

View file

@ -2,8 +2,8 @@ import type { GithubLinkPluginSettings } from "./settings";
import { GithubLinkPluginSettingsTab } from "./settings";
import { InlineRenderer } from "./inline/inline";
import { Plugin } from "obsidian";
import { QueryProcessor } from "./query/processor";
import { createInlineViewPlugin } from "./inline/view-plugin";
import { getIssue } from "./github/github";
export let PluginSettings: GithubLinkPluginSettings = { accounts: [] };
@ -13,13 +13,6 @@ export class GithubLinkPlugin extends Plugin {
this.addSettingTab(new GithubLinkPluginSettingsTab(this.app, this));
this.registerMarkdownPostProcessor(InlineRenderer);
this.registerEditorExtension(createInlineViewPlugin(this));
this.addCommand({
id: "get-issue",
name: "Get GitHub issue",
callback: async () => {
const result = await getIssue("nathonius", "obsidian-trello", 4);
console.log(result);
},
});
this.registerMarkdownCodeBlockProcessor("github-query", QueryProcessor);
}
}

24
src/query/output.ts Normal file
View file

@ -0,0 +1,24 @@
import type { SearchIssueResponse, SearchRepoResponse } from "src/github/response";
import type { TableParams } from "./params";
export function renderTable<T extends SearchIssueResponse | SearchRepoResponse>(
params: TableParams,
result: T,
el: HTMLElement,
) {
const table = el.createEl("table", { cls: "github-link-query-table" });
const thead = table.createEl("thead");
for (const col of params.columns) {
thead.createEl("th", { text: col });
}
const tbody = table.createEl("tbody");
for (const row of result.items) {
const tr = tbody.createEl("tr");
for (const col of params.columns) {
const cell = tr.createEl("td");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cell.setText((row as any)[col] ?? "");
}
}
}

50
src/query/params.ts Normal file
View file

@ -0,0 +1,50 @@
import type { SearchIssueParams, SearchRepoParams } from "src/github/response";
import { parseYaml } from "obsidian";
export enum OutputType {
Table = "table",
}
export enum QueryType {
PullRequest = "pull-request",
Issue = "issue",
Repo = "repo",
}
export interface BaseParams {
outputType: OutputType;
queryType: QueryType;
query: string;
}
export type PullRequestParams = Omit<SearchIssueParams, "q"> & BaseParams;
export type IssueParams = Omit<SearchIssueParams, "q"> & BaseParams;
export type RepoParams = Omit<SearchRepoParams, "q"> & BaseParams;
export type TableParams = { columns: string[] } & BaseParams;
export function processParams(source: string): BaseParams | null {
let params: BaseParams;
try {
params = parseYaml(source);
} catch (e) {
console.error(`Github Link: YAML Parsing failed, attempting simplistic parsing\n${e}`);
params = Object.fromEntries(source.split("\n").map((l) => l.split(/:\s?/)));
}
return params ?? null;
}
export function isPullRequestParams(params: BaseParams): params is PullRequestParams {
return params.queryType === QueryType.PullRequest;
}
export function isIssueParams(params: BaseParams): params is IssueParams {
return params.queryType === QueryType.Issue;
}
export function isRepoParams(params: BaseParams): params is RepoParams {
return params.queryType === QueryType.Repo;
}
export function isTableParams(params: BaseParams): params is TableParams {
return params.outputType === OutputType.Table;
}

25
src/query/processor.ts Normal file
View file

@ -0,0 +1,25 @@
import { type MarkdownPostProcessorContext } from "obsidian";
import { isTableParams, processParams } from "./params";
import samplePRResponse from "./samplePRResponse";
import { renderTable } from "./output";
import type { SearchIssueResponse } from "src/github/response";
export async function QueryProcessor(
source: string,
el: HTMLElement,
_ctx: MarkdownPostProcessorContext,
): Promise<void> {
const params = processParams(source);
if (!params) {
// TODO: show an error instead
el.setText(source);
return;
}
// TODO: Get result
if (isTableParams(params)) {
renderTable(params, samplePRResponse as SearchIssueResponse, el);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,25 @@
export function valueWithin(value: number, min: number, max: number) {
return value >= min && value <= max;
}
export function safeJSONParse<T>(value: string, props: Record<keyof T, boolean>): T | null {
// Handle parsing with try / catch
let parsed: T;
try {
parsed = JSON.parse(value);
} catch (err) {
return null;
}
// Validate object shape
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {};
for (const [_prop, include] of Object.entries(props)) {
const prop = _prop as keyof T;
if (include && parsed[prop as keyof T]) {
result[prop] = parsed[prop];
}
}
return result as T;
}