Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ You must also configure the plugin settings before syncing.
These settings are mandatory:

- Your GitHub Token (see below)
- Repository owner
- Repository name
- Repository URL
- Repository branch

If any of this is not set sync won't start.

The repository URL is the full URL of your repository, like `https://github.com/owner/repository`. GitHub enterprise users should enter their self-hosted url e.g. `https://github.example.com/owner/repository`.

### Token

A GitHub Fine-grained token is required to sync with your repository. You can create one by clicking [here](https://github.com/settings/personal-access-tokens/new).
Expand Down
4 changes: 2 additions & 2 deletions benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ async function runBenchmark(vaultRootDir: string) {
// Settings for the sync manager
const settings = {
githubToken: process.env.GITHUB_TOKEN,
githubOwner: process.env.REPO_OWNER,
githubRepo: process.env.REPO_NAME,
githubRepoUrl: `https://github.com/${process.env.REPO_OWNER}/${process.env.REPO_NAME}`,
githubApiBaseUrl: "",
githubBranch: process.env.REPO_BRANCH,
syncConfigDir: false,
};
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs",
"test": "tsx --test \"src/**/*.test.ts\"",
"benchmark": "tsx benchmark.ts"
},
"keywords": [],
Expand Down
60 changes: 51 additions & 9 deletions src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { requestUrl } from "obsidian";
import Logger from "src/logger";
import { GitHubSyncSettings } from "src/settings/settings";
import { retryUntil } from "src/utils";
import { resolveRepoTarget } from "./repo-url";

export type RepoContent = {
files: { [key: string]: GetTreeResponseItem };
Expand Down Expand Up @@ -59,6 +60,11 @@ class GithubAPIError extends Error {
}
}

/**
* Raised when the repository to sync can't be determined from the settings
*/
class RepoConfigurationError extends Error {}

export default class GithubClient {
constructor(
private settings: GitHubSyncSettings,
Expand All @@ -73,6 +79,27 @@ export default class GithubClient {
};
}

/**
* Builds the URL of a REST API endpoint of the configured repository.
*
* @param path Endpoint path relative to the repository, must start with a slash
* @returns The full URL to request
*/
private async repoApiUrl(path: string): Promise<string> {
const resolved = resolveRepoTarget(
this.settings.githubRepoUrl,
this.settings.githubApiBaseUrl,
);
if (!resolved.valid) {
await this.logger.error("Failed to resolve repository URL", {
error: resolved.error,
});
throw new RepoConfigurationError(resolved.error);
}
const { apiBaseUrl, owner, repo } = resolved.target;
return `${apiBaseUrl}/repos/${owner}/${repo}${path}`;
}

/**
* Gets the content of the repo.
*
Expand All @@ -84,10 +111,13 @@ export default class GithubClient {
retry = false,
maxRetries = 5,
} = {}): Promise<RepoContent> {
const url = await this.repoApiUrl(
`/git/trees/${this.settings.githubBranch}?recursive=1`,
);
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/trees/${this.settings.githubBranch}?recursive=1`,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removes hardcoded urls

url,
headers: this.headers(),
throw: false,
});
Expand Down Expand Up @@ -133,10 +163,11 @@ export default class GithubClient {
retry?: boolean;
maxRetries?: number;
}) {
const url = await this.repoApiUrl("/git/trees");
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/trees`,
url,
headers: this.headers(),
method: "POST",
body: JSON.stringify(tree),
Expand Down Expand Up @@ -180,10 +211,11 @@ export default class GithubClient {
retry?: boolean;
maxRetries?: number;
}): Promise<string> {
const url = await this.repoApiUrl("/git/commits");
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/commits`,
url,
headers: this.headers(),
method: "POST",
body: JSON.stringify({
Expand Down Expand Up @@ -216,10 +248,13 @@ export default class GithubClient {
* @returns The SHA of the branch head
*/
async getBranchHeadSha({ retry = false, maxRetries = 5 } = {}) {
const url = await this.repoApiUrl(
`/git/refs/heads/${this.settings.githubBranch}`,
);
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/refs/heads/${this.settings.githubBranch}`,
url,
headers: this.headers(),
throw: false,
});
Expand Down Expand Up @@ -254,10 +289,13 @@ export default class GithubClient {
retry?: boolean;
maxRetries?: number;
}) {
const url = await this.repoApiUrl(
`/git/refs/heads/${this.settings.githubBranch}`,
);
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/refs/heads/${this.settings.githubBranch}`,
url,
headers: this.headers(),
method: "PATCH",
body: JSON.stringify({
Expand Down Expand Up @@ -299,10 +337,11 @@ export default class GithubClient {
retry?: boolean;
maxRetries?: number;
}): Promise<CreatedBlob> {
const url = await this.repoApiUrl("/git/blobs");
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/blobs`,
url,
headers: this.headers(),
method: "POST",
body: JSON.stringify({ content, encoding }),
Expand Down Expand Up @@ -342,10 +381,11 @@ export default class GithubClient {
retry?: boolean;
maxRetries?: number;
}): Promise<BlobFile> {
const url = await this.repoApiUrl(`/git/blobs/${sha}`);
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/blobs/${sha}`,
url,
headers: this.headers(),
throw: false,
});
Expand Down Expand Up @@ -386,10 +426,11 @@ export default class GithubClient {
retry?: boolean;
maxRetries?: number;
}) {
const url = await this.repoApiUrl(`/contents/${path}`);
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/contents/${path}`,
url,
headers: this.headers(),
method: "PUT",
body: JSON.stringify({
Expand Down Expand Up @@ -424,10 +465,11 @@ export default class GithubClient {
retry = false,
maxRetries = 5,
} = {}): Promise<ArrayBuffer> {
const url = await this.repoApiUrl(`/zipball/${this.settings.githubBranch}`);
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/zipball/${this.settings.githubBranch}`,
url,
headers: this.headers(),
method: "GET",
throw: false,
Expand Down
94 changes: 94 additions & 0 deletions src/github/repo-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import test from "node:test";
import * as assert from "node:assert/strict";
import { resolveRepoTarget } from "./repo-url";

test("resolves repository URLs", () => {
Comment on lines +1 to +5

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests for sanity of URL parsing

const cases: [string, string][] = [
["https://github.com/owner/repo", "https://api.github.com"],
["https://www.github.com/owner/repo", "https://api.github.com"],
// Enterprise Server serves the API on the same host
[
"https://github.example.com/owner/repo",
"https://github.example.com/api/v3",
],
[
"http://github.internal:8443/owner/repo",
"http://github.internal:8443/api/v3",
],
// Enterprise Cloud tenants have a dedicated API subdomain
["https://acme.ghe.com/owner/repo", "https://api.acme.ghe.com"],
// Forms users are likely to paste: clone URL, trailing slash, no scheme,
// surrounding whitespace, URL of a page inside the repository, query and fragment
["https://github.com/owner/repo.git", "https://api.github.com"],
["https://github.com/owner/repo/", "https://api.github.com"],
["github.com/owner/repo", "https://api.github.com"],
[" https://github.com/owner/repo ", "https://api.github.com"],
["https://github.com/owner/repo/tree/main/notes", "https://api.github.com"],
["https://github.com/owner/repo?tab=readme#top", "https://api.github.com"],
];
for (const [repoUrl, apiBaseUrl] of cases) {
assert.deepEqual(
resolveRepoTarget(repoUrl, ""),
{ valid: true, target: { owner: "owner", repo: "repo", apiBaseUrl } },
repoUrl,
);
}
});

test("keeps the case of owner and repository", () => {
assert.deepEqual(
resolveRepoTarget("https://GitHub.com/Owner/My.Repo-1_2", ""),
{
valid: true,
target: {
owner: "Owner",
repo: "My.Repo-1_2",
apiBaseUrl: "https://api.github.com",
},
},
);
});

test("refuses URLs that don't point to a repository", () => {
const cases = [
"",
" ",
"not a url",
"ftp://github.com/owner/repo",
// SSH remotes can't be used with the REST API
"git@github.com:owner/repo.git",
"https://github.com",
"https://github.com/owner",
"https://github.com//repo",
// Characters GitHub doesn't allow in owner and repository names
"https://github.com/ow ner/repo",
"https://github.com/owner/re%20po",
];
for (const repoUrl of cases) {
assert.equal(resolveRepoTarget(repoUrl, "").valid, false, repoUrl);
}
});

test("prefers the API base URL override over the derived one", () => {
const overridden = (override: string) =>
resolveRepoTarget("https://github.example.com/owner/repo", override);

assert.deepEqual(overridden("https://api.example.com/v3/"), {
valid: true,
target: {
owner: "owner",
repo: "repo",
apiBaseUrl: "https://api.example.com/v3",
},
});
// A blank override is ignored, an unparsable one is refused instead
assert.deepEqual(overridden(" "), {
valid: true,
target: {
owner: "owner",
repo: "repo",
apiBaseUrl: "https://github.example.com/api/v3",
},
});
assert.equal(overridden("ftp://api.example.com").valid, false);
});
Loading