Compare commits

...

2 Commits

Author SHA1 Message Date
8b62f72f93 Refactor APIs, add getFileContent, update config and main. 2025-10-27 23:16:47 +00:00
0989f1ca30 Add TOML config parsing 2025-10-27 16:54:47 +00:00
10 changed files with 343 additions and 114 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/node_modules /node_modules
/dist /dist
config.toml

3
REAME.md Normal file
View File

@@ -0,0 +1,3 @@
github fine-grained permissions required: `metadata`, `content`

View File

@@ -3,7 +3,6 @@
"description": "Schedule issues in Gitea or GitHub using issue templates", "description": "Schedule issues in Gitea or GitHub using issue templates",
"version": "0.0.0", "version": "0.0.0",
"main": "dist/index.js", "main": "dist/index.js",
"author": "Luís Figueiredo (TxFig)", "author": "Luís Figueiredo (TxFig)",
"homepage": "https://gitea.alluna.pt/TxFig/issue-scheduler", "homepage": "https://gitea.alluna.pt/TxFig/issue-scheduler",
"repository": { "repository": {
@@ -12,15 +11,18 @@
}, },
"license": "ISC", "license": "ISC",
"keywords": [], "keywords": [],
"scripts": { "scripts": {
"test": "vitest --run", "test": "vitest --run",
"build": "tsc", "build": "tsc",
"start": "node dist/main.js" "start": "node dist/main.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.9.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"type": "module" "type": "module",
"dependencies": {
"toml": "^3.0.0"
}
} }

47
pnpm-lock.yaml generated
View File

@@ -7,13 +7,20 @@ settings:
importers: importers:
.: .:
dependencies:
toml:
specifier: ^3.0.0
version: 3.0.0
devDependencies: devDependencies:
'@types/node':
specifier: ^24.9.1
version: 24.9.1
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4 version: 3.2.4(@types/node@24.9.1)
packages: packages:
@@ -295,6 +302,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@24.9.1':
resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==}
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
@@ -460,11 +470,17 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
toml@3.0.0:
resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
vite-node@3.2.4: vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -699,6 +715,10 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/node@24.9.1':
dependencies:
undici-types: 7.16.0
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
dependencies: dependencies:
'@types/chai': 5.2.2 '@types/chai': 5.2.2
@@ -707,13 +727,13 @@ snapshots:
chai: 5.3.3 chai: 5.3.3
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.1.9)': '@vitest/mocker@3.2.4(vite@7.1.9(@types/node@24.9.1))':
dependencies: dependencies:
'@vitest/spy': 3.2.4 '@vitest/spy': 3.2.4
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.19 magic-string: 0.30.19
optionalDependencies: optionalDependencies:
vite: 7.1.9 vite: 7.1.9(@types/node@24.9.1)
'@vitest/pretty-format@3.2.4': '@vitest/pretty-format@3.2.4':
dependencies: dependencies:
@@ -886,15 +906,19 @@ snapshots:
tinyspy@4.0.4: {} tinyspy@4.0.4: {}
toml@3.0.0: {}
typescript@5.9.3: {} typescript@5.9.3: {}
vite-node@3.2.4: undici-types@7.16.0: {}
vite-node@3.2.4(@types/node@24.9.1):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3 debug: 4.4.3
es-module-lexer: 1.7.0 es-module-lexer: 1.7.0
pathe: 2.0.3 pathe: 2.0.3
vite: 7.1.9 vite: 7.1.9(@types/node@24.9.1)
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
- jiti - jiti
@@ -909,7 +933,7 @@ snapshots:
- tsx - tsx
- yaml - yaml
vite@7.1.9: vite@7.1.9(@types/node@24.9.1):
dependencies: dependencies:
esbuild: 0.25.10 esbuild: 0.25.10
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -918,13 +942,14 @@ snapshots:
rollup: 4.52.4 rollup: 4.52.4
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 24.9.1
fsevents: 2.3.3 fsevents: 2.3.3
vitest@3.2.4: vitest@3.2.4(@types/node@24.9.1):
dependencies: dependencies:
'@types/chai': 5.2.2 '@types/chai': 5.2.2
'@vitest/expect': 3.2.4 '@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.9) '@vitest/mocker': 3.2.4(vite@7.1.9(@types/node@24.9.1))
'@vitest/pretty-format': 3.2.4 '@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4 '@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4 '@vitest/snapshot': 3.2.4
@@ -942,9 +967,11 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinypool: 1.1.1 tinypool: 1.1.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vite: 7.1.9 vite: 7.1.9(@types/node@24.9.1)
vite-node: 3.2.4 vite-node: 3.2.4(@types/node@24.9.1)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.9.1
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less

View File

@@ -1,14 +1,21 @@
import { URL } from "url" import { URL } from "url"
import { BaseApi } from "./index.js" import { BaseApi } from "./index.js"
import type { Result } from "../result.js";
import * as result from "../result.js"
// /api/swagger // /api/swagger
const Prefix = "/api/v1" const Prefix = "/api/v1"
const IssueTemplatesUrl = (user: string, repo: string) => const urls = {
`${Prefix}/repos/${user}/${repo}/issue_templates` IssueTemplatesUrl: (user: string, repo: string) =>
`${Prefix}/repos/${user}/${repo}/issue_templates`,
FileContentUrl: (user: string, repo: string, filepath: string) =>
`${Prefix}/repos/${user}/${repo}/contents/${filepath}`
}
type GiteaIssueTemplatesResponse = { namespace GiteaApi {
export type IssueTemplatesResponse = {
name: string, name: string,
title: string, title: string,
about: string, about: string,
@@ -18,7 +25,8 @@ type GiteaIssueTemplatesResponse = {
content: string, content: string,
body: unknown | null, body: unknown | null,
file_name: string file_name: string
}[] }[]
}
class Gitea extends BaseApi { class Gitea extends BaseApi {
headers: Record<string, string> headers: Record<string, string>
@@ -30,20 +38,30 @@ class Gitea extends BaseApi {
} }
} }
async getIssueTemplates() { async getIssueTemplates(): Promise<Result<string[], string>> {
const url = new URL(IssueTemplatesUrl(this.user, this.repo), this.host) const url = new URL(
urls.IssueTemplatesUrl(this.user, this.repo),
this.host)
const response = await fetch(url, { headers: this.headers }) const response = await fetch(url, { headers: this.headers })
switch (response.status) { switch (response.status) {
case 200: break case 200: break
case 404: return { error: "Issue templates not found" } case 404: return result.error("Issue templates not found")
default: return { error: "Unknown status code" } default: return result.error("Unknown status code")
} }
const json: GiteaIssueTemplatesResponse = await response.json() const json: GiteaApi.IssueTemplatesResponse = await response.json()
return { return result.value(
value: json.map(template => template.file_name) json.map(template => template.file_name)
)
} }
async getFileContent(filepath: string): Promise<Result<string[], string>> {
const url = new URL(
urls.FileContentUrl(this.user, this.repo, filepath),
this.host)
} }
} }

View File

@@ -1,19 +1,9 @@
import { URL } from "url" import { BaseApi } from "./index.js"
import type { Result } from "../result.js"
import {BaseApi, type Result} from "./index.js" import * as result from "../result.js"
// docs.github.com/en/rest // docs.github.com/en/rest
/*
Folders searched by gitea:
"ISSUE_TEMPLATE"
"issue_template"
".gitea/ISSUE_TEMPLATE"
".gitea/issue_template"
".github/ISSUE_TEMPLATE"
".github/issue_template"
*/
const IssueTemplateFolders = [ const IssueTemplateFolders = [
"ISSUE_TEMPLATE", "ISSUE_TEMPLATE",
"issue_template", "issue_template",
@@ -23,23 +13,25 @@ const IssueTemplateFolders = [
".github/issue_template" ".github/issue_template"
] ]
// /repos/${user}/${repo} -> .default_branch
// /repos/${user}/${repo}/git/trees/{branch}?recursive=1 -> search for templates
const Prefix = "https://api.github.com" const Prefix = "https://api.github.com"
// https://docs.github.com/en/rest/repos/repos#get-a-repository const urls = {
const RepoInfoUrl = (user: string, repo: string) => // https://docs.github.com/en/rest/repos/repos#get-a-repository
`${Prefix}/repos/${user}/${repo}` RepoInfoUrl: (user: string, repo: string) =>
interface GitHubRepoInfoResponse { `${Prefix}/repos/${user}/${repo}`,
default_branch: string
// ...
}
// https://docs.github.com/en/rest/git/trees#get-a-tree // https://docs.github.com/en/rest/git/trees#get-a-tree
const TreeUrl = (user: string, repo: string, branch: string) => TreeUrl: (user: string, repo: string, branch: string) =>
`${Prefix}/repos/${user}/${repo}/git/trees/${branch}?recursive=1` `${Prefix}/repos/${user}/${repo}/git/trees/${branch}?recursive=1`
interface GitHubTreeResponse { }
namespace GitHubApi {
export interface RepoInfoResponse {
default_branch: string
// ...
}
export interface TreeResponse {
sha: string sha: string
url: string url: string
tree: { tree: {
@@ -50,9 +42,9 @@ interface GitHubTreeResponse {
size: number size: number
url: string url: string
}[] }[]
} }
// fine-grained tokens required permissions: metadata, content }
class GitHub extends BaseApi { class GitHub extends BaseApi {
headers: Record<string, string> headers: Record<string, string>
@@ -64,53 +56,67 @@ class GitHub extends BaseApi {
} }
} }
async getDefaultBranch(): Promise<Result<string, string>> { private async getDefaultBranch(): Promise<Result<string, string>> {
const url = new URL(RepoInfoUrl(this.user, this.repo), this.host) const url = urls.RepoInfoUrl(this.user, this.repo)
const response = await fetch(url, { headers: this.headers }) const response = await fetch(url, { headers: this.headers })
switch (response.status) { switch (response.status) {
case 301: return { error: "Moved permanently" } case 301: return result.error("Moved permanently")
case 403: return { error: "Forbidden" } case 403: return result.error("Forbidden")
case 404: return { error: "Resource not found" } case 404: return result.error("Resource not found")
case 200: break case 200: break
default: return { error: "Unknown status code" } default: return result.error("Unknown status code")
} }
const json: GitHubRepoInfoResponse = await response.json() const json: GitHubApi.RepoInfoResponse = await response.json()
return { value: json.default_branch } return result.value(json.default_branch)
} }
async getRepoFiles(): Promise<Result<string[], string>> { private async getRepoFiles(): Promise<Result<string[], string>> {
const branch = await this.getDefaultBranch() const branch = await this.getDefaultBranch()
if (!branch.value) return { error: `Default branch error: ${branch.error}` } if (!branch.success) return result.error(`Default branch error: ${branch.error}`)
const url = new URL(TreeUrl(this.user, this.repo, branch.value), this.host) const url = urls.TreeUrl(this.user, this.repo, branch.value)
const response = await fetch(url, { headers: this.headers }) let response: Response
try {
response = await fetch(url, { headers: this.headers })
} catch (err) {
return result.error(`Fetch Error: ${err}`)
}
switch (response.status) { switch (response.status) {
case 200: break case 200: break
case 404: return { error: "Resource not found" } case 404: return result.error("Resource not found")
case 409: return { error: "Conflict" } case 409: return result.error("Conflict")
case 422: return { error: "Validation failed, or the endpoint has been spammed" } case 422: return result.error("Validation failed, or the endpoint has been spammed")
default: return { error: "Unknown status code" } default: return result.error("Unknown status code")
} }
const json: GitHubTreeResponse = await response.json() let json: GitHubApi.TreeResponse
return { try {
value: json.tree json = await response.json()
} catch (error) {
return result.error(`Json Error: ${error}`)
}
return result.value(json.tree
.filter(entry => entry.type == "blob") .filter(entry => entry.type == "blob")
.map(entry => entry.path) .map(entry => entry.path)
} )
} }
async getIssueTemplates() { async getIssueTemplates(): Promise<Result<string[], string>> {
const files = await this.getRepoFiles() const files = await this.getRepoFiles()
if (!files.value) return { error: `Repo files error: ${files.error}` } if (!files.success) return result.error(`Repo files error: ${files.error}`)
const issue_templates = files.value.filter( const issue_templates = files.value.filter(
file => IssueTemplateFolders.some(folder => file.startsWith(folder)) file => IssueTemplateFolders.some(folder => file.startsWith(folder))
) )
return { value: issue_templates } return result.value(issue_templates)
}
async getFileContent(filepath: string): Promise<Result<string[], string>> {
} }
} }

View File

@@ -1,10 +1,5 @@
export type Result<Type, Err> = { import type { Result } from "../result.js"
value: Type,
error?: null
} | {
value?: null,
error: Err
}
export abstract class BaseApi { export abstract class BaseApi {
constructor( constructor(
@@ -18,4 +13,5 @@ export abstract class BaseApi {
* @returns Array of filepath's * @returns Array of filepath's
*/ */
abstract getIssueTemplates(): Promise<Result<string[], string>> abstract getIssueTemplates(): Promise<Result<string[], string>>
abstract getFileContent(filepath: string): Promise<Result<string, string>>
} }

91
src/config.ts Normal file
View File

@@ -0,0 +1,91 @@
import fs from "node:fs/promises"
import * as toml from "toml"
import type { Result } from "./result.js"
import * as result from "./result.js"
export interface Repository {
url: URL
token: string
client?: "gitea" | "github"
}
function isValidUrl(url: string) {
try {
new URL(url)
return true
} catch {
return false
}
}
function validateUrl(url: unknown): Result<undefined, string> {
if (!url)
return result.error("Missing url")
if (typeof url !== "string")
return result.error("Url must be a string")
if (!isValidUrl(url))
return result.error("Invalid URL")
return result.value(undefined)
}
function validateToken(token: unknown): Result<undefined, string> {
if (!token)
return result.error("Missing token")
if (typeof token !== "string")
return result.error("Token must be a string")
return result.value(undefined)
}
function validateClient(client: unknown): Result<undefined, string> {
if (!client)
return result.value(undefined)
if (typeof client !== "string")
return result.error("Client must be a string")
if (!["gitea", "github"].includes(client)) {
return result.error("Client must be \"gitea\" or \"github\"")
}
return result.value(undefined)
}
export function validateConfig(config: Record<string, any>): Result<Repository[], string> {
if (!("repo" in config) ||
!Array.isArray(config["repo"])
) return result.error("Missing repo array of tables")
for (let i = 0; i < config["repo"].length; i++) {
const repo = config["repo"][i]
const url = validateUrl(repo["url"])
if (!url.success)
return result.error(`Invalid Config (Repo#${i + 1}): ${url.error}`)
repo["url"] = new URL(repo["url"])
const token = validateToken(repo["token"])
if (!token.success)
return result.error(`Invalid Config (Repo#${i + 1}): ${token.error}`)
const client = validateClient(repo["client"])
if (!client.success)
return result.error(`Invalid Config (Repo#${i + 1}): ${client.error}`)
}
return result.value(config["repo"])
}
export async function getConfig(config_filepath: string): Promise<Result<Repository[], string>> {
let configFile: string
try {
configFile = await fs.readFile(config_filepath, "utf8")
} catch (err) {
return result.error(`Failed to open config file: "${config_filepath}"`)
}
let config: Record<string, any>
try {
config = toml.parse(configFile)
} catch (err) {
return result.error(`Failed to parse config file. ${err}`)
}
return validateConfig(config)
}

View File

@@ -1,22 +1,90 @@
import * as cli from "./cli.js" import * as cli from "./cli.js"
import { getConfig, type Repository } from "./config.js"
import type { Result } from "./result.js"
const config = cli.parseArgs(process.argv) import * as result from "./result.js"
// console.log(config)
import type { BaseApi } from "./apis"; import type { BaseApi } from "./apis";
import Gitea from "./apis/gitea.js" import Gitea from "./apis/gitea.js"
import GitHub from "./apis/github.js"; import GitHub from "./apis/github.js";
let api: BaseApi function identifyGitClient(repo: Repository): "gitea" | "github" {
api = new Gitea( if (repo.client)
"http://localhost:3000", "...", return repo.client
"admin", "test_repo"
)
api = new GitHub(
"https://api.github.com", "...",
"TxFig", "test-api")
const result = await api.getIssueTemplates() if (repo.url.host === "github.com")
console.log(result.value ?? result.error) return "github"
return "gitea"
}
function instantiateApi(repo: Repository): Result<InstanceType<typeof BaseApi>, string> {
const client = identifyGitClient(repo)
const host = repo.url.origin
const token = repo.token
const path_parts = repo.url.pathname
.split("/")
.filter(part => part && part != "/")
if (path_parts.length != 2)
return result.error("Invalid repo url")
const [user, repo_name] = path_parts as [string, string]
const ApiClass = client === "gitea" ? Gitea : GitHub
return result.value(
new ApiClass(host, token, user, repo_name)
)
}
async function handleTemplate(template_file: string): Promise<Result<undefined, string>> {
return result.value(undefined)
}
async function handleRepo(repo: Repository): Promise<Result<undefined, string>> {
const api = instantiateApi(repo)
if (!api.success)
return result.error(`instantiateApi: ${api.error}`)
const templates = await api.value.getIssueTemplates()
if (!templates.success)
return result.error(`getIssueTemplates: ${templates.error}`)
for (const template of templates.value) {
const result = await handleTemplate(template)
console.log(result)
}
return result.value(undefined)
}
async function main() {
const cliConfig = cli.parseArgs(process.argv)
const config = await getConfig(cliConfig.config_filepath)
if (!config.success) {
console.error(config.error)
process.exit(1)
}
const promise_results = await Promise.allSettled(
config.value.map(repo => handleRepo(repo))
)
for (const p_result of promise_results) {
if (p_result.status === "rejected") {
console.error(p_result.reason)
continue
}
if (!p_result.value.success) {
console.error(p_result.value.error)
continue
}
console.log(p_result.value.value)
}
}
await main()

16
src/result.ts Normal file
View File

@@ -0,0 +1,16 @@
export type Result<TValue, TError> = {
value: TValue,
success: true
} | {
error: TError,
success: false
}
export function value<TValue, TError>(val: TValue): Result<TValue, TError> {
return { value: val, success: true }
}
export function error<TValue, TError>(val: TError): Result<TValue, TError> {
return { error: val, success: false }
}