Compare commits

..

2 Commits

Author SHA1 Message Date
90ca2c2156 feat(api): implement get issue templates 2025-10-25 18:25:08 +01:00
aa1013185a feat(cli): add help message and usage documentation 2025-10-25 18:24:44 +01:00
6 changed files with 236 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "issue-scheduler", "name": "issue-scheduler",
"description": "Schedule issues in Gitea or Github from 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",

51
src/apis/gitea.ts Normal file
View File

@@ -0,0 +1,51 @@
import { URL } from "url"
import { BaseApi } from "./index.js"
// /api/swagger
const Prefix = "/api/v1"
const IssueTemplatesUrl = (user: string, repo: string) =>
`${Prefix}/repos/${user}/${repo}/issue_templates`
type GiteaIssueTemplatesResponse = {
name: string,
title: string,
about: string,
labels: string[] | null,
assignees: string[] | null,
ref: string,
content: string,
body: unknown | null,
file_name: string
}[]
class Gitea extends BaseApi {
headers: Record<string, string>
constructor(...options: ConstructorParameters<typeof BaseApi>) {
super(...options)
this.headers = {
"Authorization": `token ${this.token}`
}
}
async getIssueTemplates() {
const url = new URL(IssueTemplatesUrl(this.user, this.repo), this.host)
const response = await fetch(url, { headers: this.headers })
switch (response.status) {
case 200: break
case 404: return { error: "Issue templates not found" }
default: return { error: "Unknown status code" }
}
const json: GiteaIssueTemplatesResponse = await response.json()
return {
value: json.map(template => template.file_name)
}
}
}
export default Gitea

118
src/apis/github.ts Normal file
View File

@@ -0,0 +1,118 @@
import { URL } from "url"
import {BaseApi, type Result} from "./index.js"
// 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 = [
"ISSUE_TEMPLATE",
"issue_template",
".gitea/ISSUE_TEMPLATE",
".gitea/issue_template",
".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"
// https://docs.github.com/en/rest/repos/repos#get-a-repository
const RepoInfoUrl = (user: string, repo: string) =>
`${Prefix}/repos/${user}/${repo}`
interface GitHubRepoInfoResponse {
default_branch: string
// ...
}
// https://docs.github.com/en/rest/git/trees#get-a-tree
const TreeUrl = (user: string, repo: string, branch: string) =>
`${Prefix}/repos/${user}/${repo}/git/trees/${branch}?recursive=1`
interface GitHubTreeResponse {
sha: string
url: string
tree: {
path: string
mode: string
type: "tree" | "blob"
sha: string
size: number
url: string
}[]
}
// fine-grained tokens required permissions: metadata, content
class GitHub extends BaseApi {
headers: Record<string, string>
constructor(...options: ConstructorParameters<typeof BaseApi>) {
super(...options)
this.headers = {
"Authorization": `token ${this.token}`
}
}
async getDefaultBranch(): Promise<Result<string, string>> {
const url = new URL(RepoInfoUrl(this.user, this.repo), this.host)
const response = await fetch(url, { headers: this.headers })
switch (response.status) {
case 301: return { error: "Moved permanently" }
case 403: return { error: "Forbidden" }
case 404: return { error: "Resource not found" }
case 200: break
default: return { error: "Unknown status code" }
}
const json: GitHubRepoInfoResponse = await response.json()
return { value: json.default_branch }
}
async getRepoFiles(): Promise<Result<string[], string>> {
const branch = await this.getDefaultBranch()
if (!branch.value) return { error: `Default branch error: ${branch.error}` }
const url = new URL(TreeUrl(this.user, this.repo, branch.value), this.host)
const response = await fetch(url, { headers: this.headers })
switch (response.status) {
case 200: break
case 404: return { error: "Resource not found" }
case 409: return { error: "Conflict" }
case 422: return { error: "Validation failed, or the endpoint has been spammed" }
default: return { error: "Unknown status code" }
}
const json: GitHubTreeResponse = await response.json()
return {
value: json.tree
.filter(entry => entry.type == "blob")
.map(entry => entry.path)
}
}
async getIssueTemplates() {
const files = await this.getRepoFiles()
if (!files.value) return { error: `Repo files error: ${files.error}` }
const issue_templates = files.value.filter(
file => IssueTemplateFolders.some(folder => file.startsWith(folder))
)
return { value: issue_templates }
}
}
export default GitHub

21
src/apis/index.ts Normal file
View File

@@ -0,0 +1,21 @@
export type Result<Type, Err> = {
value: Type,
error?: null
} | {
value?: null,
error: Err
}
export abstract class BaseApi {
constructor(
readonly host: string,
readonly token: string,
readonly user: string,
readonly repo: string
) {}
/**
* @returns Array of filepath's
*/
abstract getIssueTemplates(): Promise<Result<string[], string>>
}

View File

@@ -8,6 +8,10 @@ export interface CLIConfig {
// doesn't log to stdout/stderr (still logs to file if --log-file) // doesn't log to stdout/stderr (still logs to file if --log-file)
quiet: boolean quiet: boolean
} }
// TODO: Git client shouldn't be identified from the config,
// but from the host url or a custom config property
// TODO: add --list-issue-templates <user> <repo> <token> : list results from api.getIssueTemplates
// TODO: add other utility commands
export const defaults: CLIConfig = { export const defaults: CLIConfig = {
config_filepath: "config.toml", config_filepath: "config.toml",
@@ -97,14 +101,37 @@ export function optionalBooleanFlag(args: string[], flags: string[]): boolean {
return value === null; return value === null;
} }
const Usage = (program_name: string) => `\
Usage: ${program_name} [options...]
Issue Scheduler - Schedule issues in Gitea or GitHub using issue templates
Options:
-h, --help Display this message
-c, --config <filepath> Config file [default: "config.toml"]
-l, --log-file <filepath> Logs output to file
-v, --verbose Verbose logging
-q, --quiet Only logs to --log-file (if provided)
`
/** /**
* Parse command line arguments from a string array (process.argv) * Parse command line arguments from a string array (process.argv)
* @param args Command line arguments * @param args Command line arguments
* @return {CLIConfig} Parsed flags * @return {CLIConfig} Parsed flags
*/ */
export function parseArgs(args: string[]): CLIConfig { export function parseArgs(args: string[]): CLIConfig {
if (args.length < 2) { // [node executable, program path, ...]
// TODO: log error
process.exit(1)
}
const config = defaults const config = defaults
const helpValue = optionalBooleanFlag(args, ["--help", "-h"])
if (helpValue) {
console.log(Usage(args[1]!))
process.exit(0)
}
const configValue = optionalStringFlag(args, ["--config", "-c"]) const configValue = optionalStringFlag(args, ["--config", "-c"])
if (configValue) config.config_filepath = configValue if (configValue) config.config_filepath = configValue

View File

@@ -2,4 +2,21 @@ import * as cli from "./cli.js"
const config = cli.parseArgs(process.argv) const config = cli.parseArgs(process.argv)
console.log(config) // console.log(config)
import type { BaseApi } from "./apis";
import Gitea from "./apis/gitea.js"
import GitHub from "./apis/github.js";
let api: BaseApi
api = new Gitea(
"http://localhost:3000", "...",
"admin", "test_repo"
)
api = new GitHub(
"https://api.github.com", "...",
"TxFig", "test-api")
const result = await api.getIssueTemplates()
console.log(result.value ?? result.error)