Add issue label selection
Build / build (push) Successful in 12s

This commit is contained in:
2026-07-18 13:01:36 -05:00
parent cb33bc0aa1
commit 04ee2c05a9
9 changed files with 168 additions and 104 deletions
+21 -13
View File
@@ -51,6 +51,7 @@ interface GiteaIssueResponse {
login?: string;
};
comments?: number;
labels?: GiteaLabelResponse[];
}
interface GiteaIssueCommentResponse {
@@ -67,6 +68,7 @@ interface GiteaIssueCommentResponse {
interface GiteaLabelResponse {
id: number;
name: string;
color?: string;
}
interface GiteaAttachmentResponse {
@@ -93,6 +95,7 @@ export interface GiteaIssueSummary {
updatedAt: string;
author: string;
comments: number;
labels: string[];
}
export interface GiteaIssueAttachment {
@@ -143,7 +146,8 @@ function toIssueSummary(issue: GiteaIssueResponse, owner: string, repo: string,
createdAt: issue.created_at || "",
updatedAt: issue.updated_at || "",
author: issue.user?.login || "unknown",
comments: issue.comments ?? 0
comments: issue.comments ?? 0,
labels: issue.labels?.map((label) => label.name).filter(Boolean) ?? []
};
}
@@ -245,6 +249,22 @@ export async function updateGiteaIssue(
}
}
export async function setGiteaIssueLabels(
owner: string,
repo: string,
issueNumber: number,
labelIds: number[]
): Promise<void> {
const client = createGiteaClient();
try {
await client.put(`/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
labels: labelIds
});
} catch (error) {
throw new Error(getGiteaErrorMessage(error, "Could not update the Gitea issue labels."));
}
}
export async function getGiteaIssues(
owner: string,
repo: string,
@@ -396,18 +416,6 @@ export async function ensureGiteaLabel(owner: string, repo: string, name: string
}
}
export async function addGiteaIssueLabels(
owner: string,
repo: string,
issueNumber: number,
labelIds: number[]
): Promise<void> {
const client = createGiteaClient();
await client.post(`/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
labels: labelIds
});
}
export async function getReposOrderedByLastCommit(): Promise<GiteaRepoSummary[]> {
const client = axios.create({
baseURL: getApiBaseUrl(giteaConfig.baseUrl),
+47 -25
View File
@@ -4,9 +4,11 @@ import {
appStore,
CHROME_PROFILE_NAME,
findKnownUrlForBase,
ISSUE_LABEL_OPTIONS,
type AgentProfile,
type AddUrlMode,
type AppView,
type IssueLabelOption,
type OpenIssue,
type OpenIssuesStateFilter,
type ScreenshotAttachment
@@ -389,6 +391,24 @@ function renderAgentOptions(agents: AgentProfile[], selectedAgentId: string): st
.join("");
}
function renderLabelOptions(selectedLabels: IssueLabelOption[], target: "draft" | "edit"): string {
return `
<fieldset class="label-fieldset">
<legend>Labels</legend>
<div class="label-option-grid">
${ISSUE_LABEL_OPTIONS.map(
(label) => `
<label class="checkbox-row label-option">
<input type="checkbox" value="${escapeHtml(label)}" data-label-target="${target}" ${selectedLabels.includes(label) ? "checked" : ""} />
<span>${escapeHtml(label)}</span>
</label>
`
).join("")}
</div>
</fieldset>
`;
}
function renderBodyText(body: string, emptyText: string): string {
const trimmed = body.trim();
if (!trimmed) {
@@ -540,7 +560,6 @@ function renderIssueRows(issues: OpenIssue[], selectedIssueNumber: number | null
<span class="issue-state">${escapeHtml(issue.state)}</span>
<span class="issue-meta">${formatDate(issue.createdAt)}</span>
</button>
<button class="secondary-button compact-button" type="button" data-ready-issue="${issue.number}" data-issue-repo="${escapeHtml(issue.repoFullName)}" ${state.isUpdatingIssue ? "disabled" : ""}>Ready</button>
<button class="danger-button compact-button" type="button" data-delete-issue="${issue.number}" data-issue-repo="${escapeHtml(issue.repoFullName)}" ${state.isUpdatingIssue ? "disabled" : ""}>Delete</button>
</div>
`
@@ -606,7 +625,11 @@ function renderSelectedIssueDetail(): string {
}
const draft = state.selectedIssueEditDraft;
const hasChanges = draft.subject !== detail.issue.title || draft.content !== detail.issue.body;
const detailLabels = ISSUE_LABEL_OPTIONS.filter((label) => detail.issue.labels.includes(label));
const hasChanges =
draft.subject !== detail.issue.title ||
draft.content !== detail.issue.body ||
draft.labels.join("|") !== detailLabels.join("|");
return `
<article class="item-card issue-detail-card">
@@ -629,6 +652,7 @@ function renderSelectedIssueDetail(): string {
<span>Content</span>
<textarea id="selected-issue-content" rows="8">${escapeHtml(draft.content)}</textarea>
</label>
${renderLabelOptions(draft.labels, "edit")}
<button id="save-selected-issue" class="primary-button" type="button" ${hasChanges && draft.subject.trim() && !state.isUpdatingIssue ? "" : "disabled"}>
${state.isUpdatingIssue ? "Saving issue..." : "Save issue"}
</button>
@@ -741,6 +765,8 @@ function renderAddIssue(): string {
</select>
</label>
${renderLabelOptions(draft.labels, "draft")}
<section class="attachment-section">
<div class="attachment-heading">
<span>Screenshots</span>
@@ -757,11 +783,6 @@ function renderAddIssue(): string {
</label>
${renderFileList(draft.files, "issue")}
<label class="checkbox-row">
<input id="issue-ready" type="checkbox" ${draft.ready ? "checked" : ""} />
<span>Ready</span>
</label>
<button id="create-issue" class="primary-button" type="button" ${draft.subject.trim() && !state.isCreatingIssue ? "" : "disabled"}>
${state.isCreatingIssue ? "Creating issue..." : "Create issue"}
</button>
@@ -893,12 +914,6 @@ function bindViewActions(): void {
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-ready")?.addEventListener("change", (event) => {
appStore.getState().setIssueDraft({
ready: (event.target as HTMLInputElement).checked
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-files")?.addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
appStore.getState().setIssueFiles(Array.from(input.files ?? []));
@@ -947,16 +962,6 @@ function bindViewActions(): void {
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-ready-issue]").forEach((button) => {
button.addEventListener("click", () => {
const issueNumber = Number(button.dataset.readyIssue);
const repoFullName = button.dataset.issueRepo;
if (Number.isFinite(issueNumber)) {
void appStore.getState().markIssueReady(issueNumber, repoFullName);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue]").forEach((button) => {
button.addEventListener("click", () => {
const issueNumber = Number(button.dataset.deleteIssue);
@@ -991,6 +996,21 @@ function bindViewActions(): void {
void appStore.getState().saveSelectedIssueEdits();
});
viewContent?.querySelectorAll<HTMLInputElement>("[data-label-target]").forEach((input) => {
input.addEventListener("change", () => {
const target = input.dataset.labelTarget;
const labels = Array.from(
viewContent.querySelectorAll<HTMLInputElement>(`input[data-label-target='${target}']:checked`)
).map((checkedInput) => checkedInput.value as IssueLabelOption);
if (target === "draft") {
appStore.getState().setIssueDraft({ labels });
} else if (target === "edit") {
appStore.getState().setSelectedIssueEditDraft({ labels });
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue-attachment]").forEach((button) => {
button.addEventListener("click", () => {
const attachmentId = Number(button.dataset.deleteIssueAttachment);
@@ -1221,7 +1241,7 @@ function shouldRenderView(): boolean {
}
return !activeElement.matches(
"#issue-subject, #issue-content, #issue-agent, #issue-ready, #selected-issue-subject, #selected-issue-content, #issue-comment-content, #agent-name, #agent-file, #agent-description, #agent-content"
"#issue-subject, #issue-content, #issue-agent, #selected-issue-subject, #selected-issue-content, #issue-comment-content, #agent-name, #agent-file, #agent-description, #agent-content, [data-label-target]"
);
}
@@ -1244,11 +1264,13 @@ function syncFocusedFormControls(): void {
}
if (saveSelectedIssue && state.selectedIssueDetail) {
const detailLabels = ISSUE_LABEL_OPTIONS.filter((label) => state.selectedIssueDetail?.issue.labels.includes(label));
saveSelectedIssue.disabled =
!state.selectedIssueEditDraft.subject.trim() ||
state.isUpdatingIssue ||
(state.selectedIssueEditDraft.subject === state.selectedIssueDetail.issue.title &&
state.selectedIssueEditDraft.content === state.selectedIssueDetail.issue.body);
state.selectedIssueEditDraft.content === state.selectedIssueDetail.issue.body &&
state.selectedIssueEditDraft.labels.join("|") === detailLabels.join("|"));
}
}
+58 -51
View File
@@ -1,6 +1,5 @@
import { createStore } from "zustand/vanilla";
import {
addGiteaIssueLabels,
createGiteaIssueComment,
createGiteaIssue,
deleteGiteaIssue,
@@ -11,6 +10,7 @@ import {
getGiteaIssues,
getReposOrderedByLastCommit,
giteaConfig,
setGiteaIssueLabels,
type GiteaIssueAttachment,
type GiteaIssueDetail,
type GiteaIssueStateFilter,
@@ -30,6 +30,17 @@ export type ScreenshotTarget = "issue" | "comment";
export type OpenIssuesStateFilter = GiteaIssueStateFilter;
export const CHROME_PROFILE_NAME = "Silma";
export const ISSUE_LABEL_OPTIONS = [
"Change Request",
"Idea",
"Tabitha",
"Orson",
"Belisarius",
"Ozymandias",
"Ready"
] as const;
export type IssueLabelOption = (typeof ISSUE_LABEL_OPTIONS)[number];
export interface AgentProfile {
id: string;
@@ -107,7 +118,7 @@ interface IssueDraft {
subject: string;
content: string;
agentId: string;
ready: boolean;
labels: IssueLabelOption[];
screenshots: ScreenshotAttachment[];
files: File[];
}
@@ -121,6 +132,7 @@ interface IssueCommentDraft {
interface IssueEditDraft {
subject: string;
content: string;
labels: IssueLabelOption[];
}
interface CropArea {
@@ -204,7 +216,6 @@ interface AppState {
loadOpenIssues: (force?: boolean) => Promise<void>;
loadTargetIssues: (force?: boolean) => Promise<void>;
loadSelectedIssueDetail: (force?: boolean) => Promise<void>;
markIssueReady: (issueNumber: number, repoFullName?: string) => Promise<void>;
deleteIssue: (issueNumber: number, repoFullName?: string) => Promise<void>;
deleteSelectedIssueAttachment: (attachmentId: number) => Promise<void>;
captureIssueCommentScreenshot: () => Promise<void>;
@@ -544,6 +555,15 @@ function normalizeAgents(agents: AgentProfile[]): AgentProfile[] {
return normalized.length ? normalized : AVAILABLE_AGENTS;
}
function normalizeIssueLabels(labels: string[]): IssueLabelOption[] {
return ISSUE_LABEL_OPTIONS.filter((option) => labels.some((label) => label.toLowerCase() === option.toLowerCase()));
}
async function ensureIssueLabels(owner: string, repo: string, labels: IssueLabelOption[]): Promise<number[]> {
const ensuredLabels = await Promise.all(labels.map((label) => ensureGiteaLabel(owner, repo, label)));
return ensuredLabels.map((label) => label.id);
}
function getAgentById(id: string, agents: AgentProfile[]): AgentProfile {
return agents.find((agent) => agent.id === id) ?? agents[0] ?? AVAILABLE_AGENTS[0];
}
@@ -693,7 +713,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
subject: "",
content: "",
agentId: AVAILABLE_AGENTS[0].id,
ready: false,
labels: ["Change Request"],
screenshots: [],
files: []
},
@@ -718,7 +738,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
selectedIssueDetailNumber: null,
selectedIssueEditDraft: {
subject: "",
content: ""
content: "",
labels: []
},
isLoadingData: false,
isLoadingTargetIssues: false,
@@ -984,7 +1005,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
selectedIssueDetailNumber: null,
selectedIssueEditDraft: {
subject: "",
content: ""
content: "",
labels: []
},
issueCommentDraft: {
content: "",
@@ -1003,7 +1025,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
selectedIssueDetailNumber: null,
selectedIssueEditDraft: {
subject: "",
content: ""
content: "",
labels: []
},
issueCommentDraft: {
content: "",
@@ -1031,11 +1054,13 @@ export const appStore = createStore<AppState>()((set, get) => ({
state.selectedIssueDetail
? {
subject: state.selectedIssueDetail.issue.title,
content: state.selectedIssueDetail.issue.body
content: state.selectedIssueDetail.issue.body,
labels: normalizeIssueLabels(state.selectedIssueDetail.issue.labels)
}
: {
subject: "",
content: ""
content: "",
labels: []
},
issueCommentDraft: {
content: "",
@@ -1072,7 +1097,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
selectedIssueDetailNumber: null,
selectedIssueEditDraft: {
subject: "",
content: ""
content: "",
labels: []
}
});
return;
@@ -1148,7 +1174,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
? current.selectedIssueEditDraft
: {
subject: "",
content: ""
content: "",
labels: []
}
}));
} catch (error) {
@@ -1236,7 +1263,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
selectedIssueDetailNumber: issueNumber,
selectedIssueEditDraft: {
subject: selectedIssueDetail.issue.title,
content: selectedIssueDetail.issue.body
content: selectedIssueDetail.issue.body,
labels: normalizeIssueLabels(selectedIssueDetail.issue.labels)
}
});
} catch (error) {
@@ -1274,11 +1302,17 @@ export const appStore = createStore<AppState>()((set, get) => ({
setStatus(set, `Saving issue #${issueNumber}...`, "neutral", { autoClear: false });
try {
const labelIds = await ensureIssueLabels(target.owner, target.repo, state.selectedIssueEditDraft.labels);
const updatedIssue = await updateGiteaIssue(target.owner, target.repo, issueNumber, {
title: subject,
body: state.selectedIssueEditDraft.content
});
const updatedOpenIssue = toOpenIssue(updatedIssue, target);
await setGiteaIssueLabels(target.owner, target.repo, issueNumber, labelIds);
const issueWithLabels = {
...updatedIssue,
labels: state.selectedIssueEditDraft.labels
};
const updatedOpenIssue = toOpenIssue(issueWithLabels, target);
set((current) => ({
openIssues: current.openIssues.map((issue) =>
@@ -1291,13 +1325,14 @@ export const appStore = createStore<AppState>()((set, get) => ({
...current.selectedIssueDetail,
issue: {
...current.selectedIssueDetail.issue,
...updatedIssue
...issueWithLabels
}
}
: current.selectedIssueDetail,
selectedIssueEditDraft: {
subject: updatedIssue.title,
content: updatedIssue.body
subject: issueWithLabels.title,
content: issueWithLabels.body,
labels: normalizeIssueLabels(issueWithLabels.labels)
}
}));
@@ -1310,31 +1345,6 @@ export const appStore = createStore<AppState>()((set, get) => ({
set({ isUpdatingIssue: false });
}
},
markIssueReady: async (issueNumber, repoFullName) => {
const target = getIssueTarget(appStore.getState(), repoFullName);
if (!target) {
setStatus(set, "Open a known URL with a linked Gitea repo before updating an issue.", "error");
return;
}
set({ isUpdatingIssue: true });
setStatus(set, `Applying ready label to issue #${issueNumber}...`, "neutral", { autoClear: false });
try {
const readyLabel = await ensureGiteaLabel(target.owner, target.repo, "ready");
await addGiteaIssueLabels(target.owner, target.repo, issueNumber, [readyLabel.id]);
await appStore.getState().loadOpenIssues(true);
await appStore.getState().loadTargetIssues(true);
await appStore.getState().loadSelectedIssueDetail(true);
setStatus(set, `Applied ready label to issue #${issueNumber}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not apply the ready label.";
setStatus(set, message, "error");
} finally {
set({ isUpdatingIssue: false });
}
},
deleteIssue: async (issueNumber, repoFullName) => {
const target = getIssueTarget(appStore.getState(), repoFullName);
@@ -1373,7 +1383,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
? {
subject: "",
content: ""
content: "",
labels: []
}
: state.selectedIssueEditDraft,
issueCommentDraft:
@@ -1551,13 +1562,13 @@ export const appStore = createStore<AppState>()((set, get) => ({
setStatus(set, "Creating Gitea issue...", "neutral", { autoClear: false });
try {
const changeRequestLabel = await ensureGiteaLabel(target.owner, target.repo, "Change Request", "1d76db");
const labelIds = await ensureIssueLabels(target.owner, target.repo, state.issueDraft.labels);
const issue = await createGiteaIssue(
target.owner,
target.repo,
subject,
buildIssueBody(state, target.knownUrl),
[changeRequestLabel.id]
labelIds
);
const agent = getAgentById(state.issueDraft.agentId, state.agents);
const uploads = [
@@ -1579,17 +1590,12 @@ export const appStore = createStore<AppState>()((set, get) => ({
await uploadGiteaIssueAttachment(target.owner, target.repo, issue.number, upload.blob, upload.name);
}
if (state.issueDraft.ready) {
const readyLabel = await ensureGiteaLabel(target.owner, target.repo, "ready");
await addGiteaIssueLabels(target.owner, target.repo, issue.number, [readyLabel.id]);
}
set({
issueDraft: {
subject: "",
content: "",
agentId: appStore.getState().agents[0]?.id ?? AVAILABLE_AGENTS[0].id,
ready: false,
labels: ["Change Request"],
screenshots: [],
files: []
},
@@ -1656,7 +1662,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
? {
subject: "",
content: ""
content: "",
labels: []
}
: get().selectedIssueEditDraft
});
+25 -1
View File
@@ -446,6 +446,30 @@ fieldset {
padding: 8px;
}
.label-fieldset {
padding: 6px;
}
.label-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 3px 7px;
}
.label-option {
min-width: 0;
}
.label-option span {
min-width: 0;
overflow: hidden;
color: #334e68;
font-size: 11px;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio-row {
display: flex;
grid-template-columns: auto 1fr;
@@ -512,7 +536,7 @@ input[readonly] {
.issue-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 5px;
width: 100%;