Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan
9bc8dcc053 feat(issues): add Relations section in sidebar for managing issue dependencies
Users can now create, view, and remove issue dependencies directly from
the issue detail sidebar. The Relations section shows existing links with
type labels (blocks, blocked by, related to) and a searchable popover
for adding new relations. Activity entries appear on both issues when
relations change.
2026-04-04 00:05:15 +08:00
Jiayuan
473ec17c65 feat(activity): add issue relation activities to timeline
When issue dependencies are created or removed, both the source and
target issues now receive activity entries in their timeline. This
enables bi-directional visibility — e.g., issue A's timeline shows
"added relation: blocks MUL-456" while issue B shows "added relation:
blocked by MUL-123".

Changes:
- Add CRUD SQL queries and handler endpoints for issue_dependency
- Add issue_dependency:created/removed event types
- Add activity listener that records activities on both issues
- Render relation activities in the frontend with Link2 icon
2026-04-03 23:51:36 +08:00
11 changed files with 604 additions and 2 deletions

View File

@@ -185,6 +185,9 @@ vi.mock("@/shared/api", () => ({
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
listDependencies: vi.fn().mockResolvedValue([]),
createDependency: vi.fn().mockResolvedValue({}),
deleteDependency: vi.fn().mockResolvedValue(undefined),
},
}));

View File

@@ -13,6 +13,7 @@ import {
Link2,
MoreHorizontal,
PanelRight,
Plus,
Trash2,
UserMinus,
Users,
@@ -57,7 +58,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, IssueDependency, IssueDependencyType, TimelineEntry } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
import { CommentCard } from "./comment-card";
@@ -125,11 +126,42 @@ function formatActivity(
return "completed the task";
case "task_failed":
return "task failed";
case "issue_relation_added": {
const relType = details.relation_type ?? "";
const identifier = details.related_issue_identifier ?? "?";
const label = relationTypeLabel(relType);
return `added relation: ${label} ${identifier}`;
}
case "issue_relation_removed": {
const relType = details.relation_type ?? "";
const identifier = details.related_issue_identifier ?? "?";
const label = relationTypeLabel(relType);
return `removed relation: ${label} ${identifier}`;
}
default:
return entry.action ?? "";
}
}
function relationTypeLabel(type: string): string {
switch (type) {
case "blocks":
return "blocks";
case "blocked_by":
return "blocked by";
case "related":
return "related to";
default:
return type;
}
}
function inverseRelType(type: string): string {
if (type === "blocks") return "blocked_by";
if (type === "blocked_by") return "blocks";
return type;
}
// ---------------------------------------------------------------------------
// Property row
@@ -194,7 +226,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const [deleting, setDeleting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [relationsOpen, setRelationsOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
const [dependencies, setDependencies] = useState<IssueDependency[]>([]);
const [addRelOpen, setAddRelOpen] = useState(false);
const [relSearch, setRelSearch] = useState("");
const [relType, setRelType] = useState<IssueDependencyType>("related");
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
@@ -240,6 +277,32 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const loading = issueLoading;
// Fetch issue dependencies
const fetchDependencies = useCallback(() => {
api.listDependencies(id).then(setDependencies).catch(() => {});
}, [id]);
useEffect(() => { fetchDependencies(); }, [fetchDependencies]);
const handleAddDependency = useCallback(async (targetIssueId: string, type: IssueDependencyType) => {
try {
await api.createDependency(id, targetIssueId, type);
fetchDependencies();
setAddRelOpen(false);
setRelSearch("");
} catch {
toast.error("Failed to add relation");
}
}, [id, fetchDependencies]);
const handleRemoveDependency = useCallback(async (depId: string) => {
try {
await api.deleteDependency(id, depId);
fetchDependencies();
} catch {
toast.error("Failed to remove relation");
}
}, [id, fetchDependencies]);
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
useEffect(() => {
if (!highlightCommentId || timeline.length === 0) return;
@@ -871,6 +934,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
const isRelationChange = entry.action === "issue_relation_added" || entry.action === "issue_relation_removed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
@@ -879,6 +943,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else if (isRelationChange) {
leadIcon = <Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
@@ -1021,6 +1087,102 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>}
</div>
{/* Relations section */}
<div>
<div className="flex items-center mb-2">
<button
className={`flex flex-1 items-center gap-1 text-xs font-medium transition-colors ${relationsOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setRelationsOpen(!relationsOpen)}
>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${relationsOpen ? "rotate-90" : ""}`} />
Relations
{dependencies.length > 0 && <span className="text-muted-foreground ml-1">({dependencies.length})</span>}
</button>
<Popover open={addRelOpen} onOpenChange={setAddRelOpen}>
<PopoverTrigger
render={
<button className="p-0.5 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground transition-colors">
<Plus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align="end" className="w-72 p-0">
<div className="p-2 border-b">
<div className="flex gap-1 mb-2">
{(["related", "blocks", "blocked_by"] as const).map((t) => (
<button
key={t}
className={`px-2 py-0.5 text-xs rounded-full transition-colors ${relType === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"}`}
onClick={() => setRelType(t)}
>
{relationTypeLabel(t)}
</button>
))}
</div>
</div>
<Command>
<CommandInput placeholder="Search issues..." value={relSearch} onValueChange={setRelSearch} />
<CommandList>
<CommandEmpty>No issues found</CommandEmpty>
<CommandGroup>
{allIssues
.filter((i) => i.id !== id)
.filter((i) => !dependencies.some(
(d) => (d.issue_id === id ? d.depends_on_issue_id : d.issue_id) === i.id
))
.slice(0, 20)
.map((i) => (
<CommandItem
key={i.id}
value={`${i.identifier} ${i.title}`}
onSelect={() => handleAddDependency(i.id, relType)}
>
<span className="text-muted-foreground shrink-0 mr-1.5">{i.identifier}</span>
<span className="truncate">{i.title}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{relationsOpen && dependencies.length > 0 && (
<div className="space-y-1 pl-2">
{dependencies.map((dep) => {
// Determine which side is the "other" issue
const isSource = dep.issue_id === id;
const otherIdentifier = isSource ? dep.depends_on_issue_identifier : dep.issue_identifier;
const otherTitle = isSource ? dep.depends_on_issue_title : dep.issue_title;
const otherIssueId = isSource ? dep.depends_on_issue_id : dep.issue_id;
// Show the relation type from this issue's perspective
const displayType = isSource ? dep.type : inverseRelType(dep.type);
return (
<div key={dep.id} className="group flex items-center gap-1.5 text-xs rounded-md px-2 -mx-2 min-h-7 hover:bg-accent/50 transition-colors">
<Link2 className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="shrink-0 text-muted-foreground">{relationTypeLabel(displayType)}</span>
<Link href={`/issues/${otherIssueId}`} className="flex items-center gap-1 min-w-0 hover:underline">
<span className="shrink-0 text-muted-foreground">{otherIdentifier}</span>
<span className="truncate">{otherTitle}</span>
</Link>
<button
className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent transition-all text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveDependency(dep.id)}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
)}
{relationsOpen && dependencies.length === 0 && (
<div className="pl-2 text-xs text-muted-foreground">No relations</div>
)}
</div>
{/* Details section */}
<div>
<button

View File

@@ -35,6 +35,8 @@ import type {
TimelineEntry,
TaskMessagePayload,
Attachment,
IssueDependency,
IssueDependencyType,
} from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
@@ -226,6 +228,22 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/timeline`);
}
// Issue dependencies
async listDependencies(issueId: string): Promise<IssueDependency[]> {
return this.fetch(`/api/issues/${issueId}/dependencies`);
}
async createDependency(issueId: string, dependsOnIssueId: string, type: IssueDependencyType): Promise<IssueDependency> {
return this.fetch(`/api/issues/${issueId}/dependencies`, {
method: "POST",
body: JSON.stringify({ depends_on_issue_id: dependsOnIssueId, type }),
});
}
async deleteDependency(issueId: string, depId: string): Promise<void> {
await this.fetch(`/api/issues/${issueId}/dependencies/${depId}`, { method: "DELETE" });
}
async updateComment(commentId: string, content: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",

View File

@@ -1,4 +1,4 @@
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction } from "./issue";
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction, IssueDependency, IssueDependencyType } from "./issue";
export type {
Agent,
AgentStatus,

View File

@@ -20,6 +20,19 @@ export interface IssueReaction {
created_at: string;
}
export type IssueDependencyType = "blocks" | "blocked_by" | "related";
export interface IssueDependency {
id: string;
issue_id: string;
depends_on_issue_id: string;
type: IssueDependencyType;
issue_identifier: string;
issue_title: string;
depends_on_issue_identifier: string;
depends_on_issue_title: string;
}
export interface Issue {
id: string;
workspace_id: string;

View File

@@ -220,6 +220,16 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) {
bus.Subscribe(protocol.EventTaskFailed, func(e events.Event) {
handleTaskActivity(ctx, bus, queries, e, "task_failed")
})
// issue_dependency:created — record "issue_relation_added" on both issues
bus.Subscribe(protocol.EventIssueDependencyCreated, func(e events.Event) {
handleDependencyActivity(ctx, bus, queries, e, "issue_relation_added")
})
// issue_dependency:removed — record "issue_relation_removed" on both issues
bus.Subscribe(protocol.EventIssueDependencyRemoved, func(e events.Event) {
handleDependencyActivity(ctx, bus, queries, e, "issue_relation_removed")
})
}
// handleTaskActivity records an activity for task:completed or task:failed events.
@@ -259,6 +269,81 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
publishActivityEvent(bus, e, activity)
}
// handleDependencyActivity records activities on both issues when a dependency
// is created or removed. Each issue gets an activity entry with details about
// the related issue (identifier, title, relation type).
func handleDependencyActivity(ctx context.Context, bus *events.Bus, queries *db.Queries, e events.Event, action string) {
payload, ok := e.Payload.(map[string]any)
if !ok {
return
}
dep, _ := payload["dependency"].(db.IssueDependency)
issue, _ := payload["issue"].(db.Issue)
targetIssue, _ := payload["target_issue"].(db.Issue)
issueIdentifier, _ := payload["issue_identifier"].(string)
targetIdentifier, _ := payload["target_issue_identifier"].(string)
issueID := util.UUIDToString(dep.IssueID)
targetIssueID := util.UUIDToString(dep.DependsOnIssueID)
// Activity on the source issue: "added relation: blocks MUL-456"
srcDetails, _ := json.Marshal(map[string]string{
"related_issue_id": targetIssueID,
"related_issue_identifier": targetIdentifier,
"related_issue_title": targetIssue.Title,
"relation_type": dep.Type,
})
srcActivity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: issue.WorkspaceID,
IssueID: dep.IssueID,
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
Action: action,
Details: srcDetails,
})
if err != nil {
slog.Error("activity: failed to record dependency activity on source issue",
"issue_id", issueID, "action", action, "error", err)
} else {
publishActivityEvent(bus, e, srcActivity)
}
// Activity on the target issue: "added relation: blocked_by MUL-123"
inverseType := inverseRelationType(dep.Type)
targetDetails, _ := json.Marshal(map[string]string{
"related_issue_id": issueID,
"related_issue_identifier": issueIdentifier,
"related_issue_title": issue.Title,
"relation_type": inverseType,
})
targetActivity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: targetIssue.WorkspaceID,
IssueID: dep.DependsOnIssueID,
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
Action: action,
Details: targetDetails,
})
if err != nil {
slog.Error("activity: failed to record dependency activity on target issue",
"issue_id", targetIssueID, "action", action, "error", err)
} else {
publishActivityEvent(bus, e, targetActivity)
}
}
// inverseRelationType returns the inverse of a dependency relation type.
func inverseRelationType(t string) string {
switch t {
case "blocks":
return "blocked_by"
case "blocked_by":
return "blocks"
default:
return t // "related" is symmetric
}
}
// publishActivityEvent sends an activity:created event for WS broadcasting.
// Payload matches frontend ActivityCreatedPayload: { issue_id, entry: TimelineEntry }
func publishActivityEvent(bus *events.Bus, original events.Event, activity db.ActivityLog) {

View File

@@ -175,6 +175,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Post("/reactions", h.AddIssueReaction)
r.Delete("/reactions", h.RemoveIssueReaction)
r.Get("/attachments", h.ListAttachments)
r.Get("/dependencies", h.ListIssueDependencies)
r.Post("/dependencies", h.CreateIssueDependency)
r.Delete("/dependencies/{depId}", h.DeleteIssueDependency)
})
})

View File

@@ -0,0 +1,209 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// IssueDependencyResponse is the JSON response for an issue dependency.
type IssueDependencyResponse struct {
ID string `json:"id"`
IssueID string `json:"issue_id"`
DependsOnIssueID string `json:"depends_on_issue_id"`
Type string `json:"type"`
// Enriched fields for the related issue
IssueIdentifier string `json:"issue_identifier"`
IssueTitle string `json:"issue_title"`
DependsOnIssueIdentifier string `json:"depends_on_issue_identifier"`
DependsOnIssueTitle string `json:"depends_on_issue_title"`
}
// ListIssueDependencies returns all dependencies for a given issue.
func (h *Handler) ListIssueDependencies(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
deps, err := h.Queries.ListIssueDependencies(r.Context(), issue.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list dependencies")
return
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
result := make([]IssueDependencyResponse, 0, len(deps))
for _, d := range deps {
resp := IssueDependencyResponse{
ID: uuidToString(d.ID),
IssueID: uuidToString(d.IssueID),
DependsOnIssueID: uuidToString(d.DependsOnIssueID),
Type: d.Type,
}
// Enrich with issue identifiers and titles
if srcIssue, err := h.Queries.GetIssue(r.Context(), d.IssueID); err == nil {
resp.IssueIdentifier = prefix + "-" + strconv.Itoa(int(srcIssue.Number))
resp.IssueTitle = srcIssue.Title
}
if depIssue, err := h.Queries.GetIssue(r.Context(), d.DependsOnIssueID); err == nil {
resp.DependsOnIssueIdentifier = prefix + "-" + strconv.Itoa(int(depIssue.Number))
resp.DependsOnIssueTitle = depIssue.Title
}
result = append(result, resp)
}
writeJSON(w, http.StatusOK, result)
}
// CreateIssueDependency creates a new dependency between two issues.
func (h *Handler) CreateIssueDependency(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
var req struct {
DependsOnIssueID string `json:"depends_on_issue_id"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.DependsOnIssueID == "" || req.Type == "" {
writeError(w, http.StatusBadRequest, "depends_on_issue_id and type are required")
return
}
// Validate relation type
switch req.Type {
case "blocks", "blocked_by", "related":
default:
writeError(w, http.StatusBadRequest, "type must be one of: blocks, blocked_by, related")
return
}
// Prevent self-reference
if uuidToString(issue.ID) == req.DependsOnIssueID {
writeError(w, http.StatusBadRequest, "cannot create a dependency to the same issue")
return
}
// Verify the target issue exists and belongs to the same workspace
targetIssue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: parseUUID(req.DependsOnIssueID),
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
writeError(w, http.StatusNotFound, "target issue not found")
return
}
dep, err := h.Queries.CreateIssueDependency(r.Context(), db.CreateIssueDependencyParams{
IssueID: issue.ID,
DependsOnIssueID: parseUUID(req.DependsOnIssueID),
Type: req.Type,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "dependency already exists")
return
}
writeError(w, http.StatusInternalServerError, "failed to create dependency")
return
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
issueIdentifier := prefix + "-" + strconv.Itoa(int(issue.Number))
targetIdentifier := prefix + "-" + strconv.Itoa(int(targetIssue.Number))
userID := requestUserID(r)
workspaceID := uuidToString(issue.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
// Publish event for activity listener
h.publish(protocol.EventIssueDependencyCreated, workspaceID, actorType, actorID, map[string]any{
"dependency": dep,
"issue": issue,
"target_issue": targetIssue,
"issue_identifier": issueIdentifier,
"target_issue_identifier": targetIdentifier,
})
resp := IssueDependencyResponse{
ID: uuidToString(dep.ID),
IssueID: uuidToString(dep.IssueID),
DependsOnIssueID: uuidToString(dep.DependsOnIssueID),
Type: dep.Type,
IssueIdentifier: issueIdentifier,
IssueTitle: issue.Title,
DependsOnIssueIdentifier: targetIdentifier,
DependsOnIssueTitle: targetIssue.Title,
}
writeJSON(w, http.StatusCreated, resp)
}
// DeleteIssueDependency removes a dependency between two issues.
func (h *Handler) DeleteIssueDependency(w http.ResponseWriter, r *http.Request) {
issueIDStr := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueIDStr)
if !ok {
return
}
depID := chi.URLParam(r, "depId")
dep, err := h.Queries.GetIssueDependency(r.Context(), parseUUID(depID))
if err != nil {
writeError(w, http.StatusNotFound, "dependency not found")
return
}
// Verify the dependency belongs to this issue
depIssueID := uuidToString(dep.IssueID)
depTargetID := uuidToString(dep.DependsOnIssueID)
issueID := uuidToString(issue.ID)
if depIssueID != issueID && depTargetID != issueID {
writeError(w, http.StatusNotFound, "dependency not found")
return
}
// Look up both issues for activity details
srcIssue, _ := h.Queries.GetIssue(r.Context(), dep.IssueID)
targetIssue, _ := h.Queries.GetIssue(r.Context(), dep.DependsOnIssueID)
if err := h.Queries.DeleteIssueDependency(r.Context(), dep.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete dependency")
return
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
srcIdentifier := prefix + "-" + strconv.Itoa(int(srcIssue.Number))
targetIdentifier := prefix + "-" + strconv.Itoa(int(targetIssue.Number))
userID := requestUserID(r)
workspaceID := uuidToString(issue.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventIssueDependencyRemoved, workspaceID, actorType, actorID, map[string]any{
"dependency": dep,
"issue": srcIssue,
"target_issue": targetIssue,
"issue_identifier": srcIdentifier,
"target_issue_identifier": targetIdentifier,
})
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,91 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: issue_dependency.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createIssueDependency = `-- name: CreateIssueDependency :one
INSERT INTO issue_dependency (issue_id, depends_on_issue_id, type)
VALUES ($1, $2, $3)
RETURNING id, issue_id, depends_on_issue_id, type
`
type CreateIssueDependencyParams struct {
IssueID pgtype.UUID `json:"issue_id"`
DependsOnIssueID pgtype.UUID `json:"depends_on_issue_id"`
Type string `json:"type"`
}
func (q *Queries) CreateIssueDependency(ctx context.Context, arg CreateIssueDependencyParams) (IssueDependency, error) {
row := q.db.QueryRow(ctx, createIssueDependency, arg.IssueID, arg.DependsOnIssueID, arg.Type)
var i IssueDependency
err := row.Scan(
&i.ID,
&i.IssueID,
&i.DependsOnIssueID,
&i.Type,
)
return i, err
}
const deleteIssueDependency = `-- name: DeleteIssueDependency :exec
DELETE FROM issue_dependency WHERE id = $1
`
func (q *Queries) DeleteIssueDependency(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteIssueDependency, id)
return err
}
const getIssueDependency = `-- name: GetIssueDependency :one
SELECT id, issue_id, depends_on_issue_id, type FROM issue_dependency WHERE id = $1
`
func (q *Queries) GetIssueDependency(ctx context.Context, id pgtype.UUID) (IssueDependency, error) {
row := q.db.QueryRow(ctx, getIssueDependency, id)
var i IssueDependency
err := row.Scan(
&i.ID,
&i.IssueID,
&i.DependsOnIssueID,
&i.Type,
)
return i, err
}
const listIssueDependencies = `-- name: ListIssueDependencies :many
SELECT id, issue_id, depends_on_issue_id, type FROM issue_dependency
WHERE issue_id = $1 OR depends_on_issue_id = $1
`
func (q *Queries) ListIssueDependencies(ctx context.Context, issueID pgtype.UUID) ([]IssueDependency, error) {
rows, err := q.db.Query(ctx, listIssueDependencies, issueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []IssueDependency{}
for rows.Next() {
var i IssueDependency
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.DependsOnIssueID,
&i.Type,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,14 @@
-- name: CreateIssueDependency :one
INSERT INTO issue_dependency (issue_id, depends_on_issue_id, type)
VALUES ($1, $2, $3)
RETURNING *;
-- name: DeleteIssueDependency :exec
DELETE FROM issue_dependency WHERE id = $1;
-- name: GetIssueDependency :one
SELECT * FROM issue_dependency WHERE id = $1;
-- name: ListIssueDependencies :many
SELECT * FROM issue_dependency
WHERE issue_id = $1 OR depends_on_issue_id = $1;

View File

@@ -7,6 +7,10 @@ const (
EventIssueUpdated = "issue:updated"
EventIssueDeleted = "issue:deleted"
// Issue dependency events
EventIssueDependencyCreated = "issue_dependency:created"
EventIssueDependencyRemoved = "issue_dependency:removed"
// Comment events
EventCommentCreated = "comment:created"
EventCommentUpdated = "comment:updated"