feat(tasks): Aufgabe als Git-Issue anlegen, Zustand wandert mit
Die Zuordnung stand im Schema laengst – sourceIssue, sourceUrl und sourceRepositoryId samt Eindeutigkeit je Repository, gefuellt von der Uebernahme aus Issues seit v0.9.0. Es fehlte die Gegenrichtung. Ein Knopf im Aufgabendialog legt das Issue an; Nummer und Adresse landen in denselben Feldern, der Dublettenschutz gilt also fuer beide Wege. In den Rumpf kommt ein Rueckverweis auf das Projekt. Ausdruecklich per Knopf und nie nebenbei: was in ein fremdes System geschrieben wird, soll jemand gewollt haben. Ist die Aufgabe erst verknuepft, wandert der Zustand allerdings mit – erledigt schliesst das Issue, ein Zurueckziehen oeffnet es wieder. Fehlertolerant: ein nicht erreichbares Git-System darf das Abhaken nicht verhindern. Die Zustaende passen nicht aufeinander, und das bleibt so. Nebula kennt vier, ein Issue zwei; nach aussen geht nur die Grenze um ERLEDIGT. Ein Rundlauf verliert damit IN ARBEIT und BLOCKIERT. Labels waeren die Alternative, wuerden aber den Issue-Tracker mit Zustaenden fuellen, die dort niemand pflegt. Kein Widerspruch zu "keine eigenen Git-Operationen": Issues laufen ueber die HTTP-API der Provider. Dafuer gibt es jetzt sendJson neben getJson – durch dieselbe SSRF-Schleuse, aber ohne Umleitungen zu folgen. Ein 307 auf eine interne Adresse naehme den Rumpf und den Token im Header mit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+28
-1
@@ -1,4 +1,4 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
||||
import {
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
CiState,
|
||||
CommitInfo,
|
||||
ConnectionCheck,
|
||||
CreatedIssue,
|
||||
GitClient,
|
||||
ProviderConfig,
|
||||
ReleaseInfo,
|
||||
@@ -44,6 +45,32 @@ export class GiteaClient implements GitClient {
|
||||
return headers;
|
||||
}
|
||||
|
||||
private repoBase(ref: RepoRef): string {
|
||||
return `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
ref: RepoRef,
|
||||
issue: { title: string; body?: string | null },
|
||||
): Promise<CreatedIssue> {
|
||||
const angelegt = await sendJson<{ number: number; html_url?: string }>(
|
||||
`${this.repoBase(ref)}/issues`,
|
||||
"POST",
|
||||
{ title: issue.title, body: issue.body ?? "" },
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
||||
}
|
||||
|
||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
||||
await sendJson(
|
||||
`${this.repoBase(ref)}/issues/${number}`,
|
||||
"PATCH",
|
||||
{ state: open ? "open" : "closed" },
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
}
|
||||
|
||||
async check(): Promise<ConnectionCheck> {
|
||||
try {
|
||||
const version = await getJsonSafe<{ version?: string }>(
|
||||
|
||||
+28
-1
@@ -1,4 +1,4 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CommitCalendar,
|
||||
CommitInfo,
|
||||
ConnectionCheck,
|
||||
CreatedIssue,
|
||||
GitClient,
|
||||
ProviderConfig,
|
||||
ReleaseInfo,
|
||||
@@ -43,6 +44,32 @@ export class GitHubClient implements GitClient {
|
||||
return headers;
|
||||
}
|
||||
|
||||
private repoBase(ref: RepoRef): string {
|
||||
return `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
ref: RepoRef,
|
||||
issue: { title: string; body?: string | null },
|
||||
): Promise<CreatedIssue> {
|
||||
const angelegt = await sendJson<{ number: number; html_url?: string }>(
|
||||
`${this.repoBase(ref)}/issues`,
|
||||
"POST",
|
||||
{ title: issue.title, body: issue.body ?? "" },
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
||||
}
|
||||
|
||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
||||
await sendJson(
|
||||
`${this.repoBase(ref)}/issues/${number}`,
|
||||
"PATCH",
|
||||
{ state: open ? "open" : "closed" },
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
}
|
||||
|
||||
async check(): Promise<ConnectionCheck> {
|
||||
try {
|
||||
if (!this.token) {
|
||||
|
||||
+26
-1
@@ -1,4 +1,4 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
||||
import {
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
CiState,
|
||||
CommitInfo,
|
||||
ConnectionCheck,
|
||||
CreatedIssue,
|
||||
GitClient,
|
||||
ProviderConfig,
|
||||
ReleaseInfo,
|
||||
@@ -45,6 +46,30 @@ export class GitLabClient implements GitClient {
|
||||
return headers;
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
ref: RepoRef,
|
||||
issue: { title: string; body?: string | null },
|
||||
): Promise<CreatedIssue> {
|
||||
// GitLab nennt es "iid" – die projektinterne Nummer, die auch in der
|
||||
// Oberflaeche steht. "id" waere die instanzweite und damit die falsche.
|
||||
const angelegt = await sendJson<{ iid: number; web_url?: string }>(
|
||||
`${this.apiBase}/projects/${this.projectPath(ref)}/issues`,
|
||||
"POST",
|
||||
{ title: issue.title, description: issue.body ?? "" },
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
return { number: angelegt.iid, url: angelegt.web_url ?? null };
|
||||
}
|
||||
|
||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
||||
await sendJson(
|
||||
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||
"PUT",
|
||||
{ state_event: open ? "reopen" : "close" },
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
}
|
||||
|
||||
async check(): Promise<ConnectionCheck> {
|
||||
try {
|
||||
if (!this.token) {
|
||||
|
||||
@@ -98,6 +98,83 @@ export async function getJson<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibender Aufruf – POST, PATCH oder PUT.
|
||||
*
|
||||
* Bewusst dieselbe Schleuse wie getJson: die Basis-URL stammt aus einem
|
||||
* Formular, und ohne die Pruefung waere ein schreibender Aufruf der weitaus
|
||||
* unangenehmere Weg nach innen. Redirects werden hier **nicht** verfolgt.
|
||||
* Ein 307 auf eine interne Adresse wuerde den Rumpf mitnehmen, und der
|
||||
* enthaelt bei uns immer den Token im Header – lieber ein Fehler als eine
|
||||
* Anfrage, die woanders landet.
|
||||
*/
|
||||
export async function sendJson<T>(
|
||||
url: string,
|
||||
method: "POST" | "PATCH" | "PUT",
|
||||
body: unknown,
|
||||
options: FetchOptions = {},
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.timeoutMs ?? DEFAULT_TIMEOUT,
|
||||
);
|
||||
|
||||
try {
|
||||
await assertAllowedUrl(url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Nebula-Dashboard",
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
// Bei redirect: "manual" kommt der Umleitungsstatus durch, statt dass
|
||||
// fetch ihm folgt. Das ist hier gewollt und ein Fehlerfall.
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new GitApiError(
|
||||
"Das Git-System hat umgeleitet. Schreibende Aufrufe folgen dem nicht – " +
|
||||
"bitte die Basis-URL der Verbindung auf die endgueltige Adresse setzen.",
|
||||
response.status,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) return null as T;
|
||||
return JSON.parse(text) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof GitApiError) throw error;
|
||||
if (error instanceof BlockedHostError) {
|
||||
throw new GitApiError(error.message, 403, url);
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new GitApiError(
|
||||
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
|
||||
408,
|
||||
url,
|
||||
);
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
|
||||
export async function getJsonSafe<T>(
|
||||
url: string,
|
||||
|
||||
@@ -167,10 +167,29 @@ export interface ProviderConfig {
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/** Ein neu angelegtes Issue, so weit wir es zurueckschreiben. */
|
||||
export interface CreatedIssue {
|
||||
number: number;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface GitClient {
|
||||
readonly provider: ProviderKind;
|
||||
/** Prueft Erreichbarkeit und Token-Gueltigkeit. */
|
||||
check(): Promise<ConnectionCheck>;
|
||||
/**
|
||||
* Legt ein Issue an und liefert Nummer und Adresse zurueck.
|
||||
*
|
||||
* Der einzige schreibende Aufruf in dieser Schnittstelle. Er wird nie
|
||||
* nebenbei ausgeloest, sondern immer von Hand angestossen – was nach
|
||||
* aussen geht, soll jemand gewollt haben.
|
||||
*/
|
||||
createIssue(ref: RepoRef, issue: { title: string; body?: string | null }): Promise<CreatedIssue>;
|
||||
/**
|
||||
* Schliesst ein Issue. Beim Zurueckziehen einer Aufgabe wird es wieder
|
||||
* geoeffnet – daher der Schalter statt zweier Methoden.
|
||||
*/
|
||||
setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>;
|
||||
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
|
||||
listRepos(query?: string): Promise<RepoListItem[]>;
|
||||
/** Vollstaendiger Stand eines Repositories. */
|
||||
|
||||
Reference in New Issue
Block a user