Compare commits
2 Commits
c111fd6689
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
90ca2c2156
|
|||
|
aa1013185a
|
@@ -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
51
src/apis/gitea.ts
Normal 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
118
src/apis/github.ts
Normal 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
21
src/apis/index.ts
Normal 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>>
|
||||||
|
}
|
||||||
27
src/cli.ts
27
src/cli.ts
@@ -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
|
||||||
|
|
||||||
|
|||||||
19
src/index.ts
19
src/index.ts
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user