/ frontend / src / components / rollback / DependencyGraph.tsx
DependencyGraph.tsx
  1  import { useQuery } from '@tanstack/react-query'
  2  import type { Chain } from '../../types/vote'
  3  import * as rollbackService from '../../services/rollback'
  4  
  5  interface DependencyGraphProps {
  6    repoId: string
  7    chain: Chain
  8    highlightPrHash?: string
  9  }
 10  
 11  export default function DependencyGraph({
 12    repoId,
 13    chain,
 14    highlightPrHash,
 15  }: DependencyGraphProps) {
 16    const { data: dependencies = [], isLoading } = useQuery({
 17      queryKey: ['prDependencies', repoId, chain],
 18      queryFn: () => rollbackService.getPRDependencies(repoId, chain),
 19    })
 20  
 21    if (isLoading) {
 22      return (
 23        <div className="bg-white border rounded-lg p-6">
 24          <div className="flex justify-center">
 25            <div className="animate-spin h-6 w-6 border-2 border-gray-400 border-t-transparent rounded-full" />
 26          </div>
 27        </div>
 28      )
 29    }
 30  
 31    if (dependencies.length === 0) {
 32      return (
 33        <div className="bg-white border rounded-lg p-6">
 34          <p className="text-gray-500 text-sm text-center">
 35            No PR dependencies found for this repository.
 36          </p>
 37        </div>
 38      )
 39    }
 40  
 41    // Build graph structure
 42    const nodes = new Map<string, DependencyNode>()
 43    for (const dep of dependencies) {
 44      nodes.set(dep.prHash, {
 45        prHash: dep.prHash,
 46        dependsOn: dep.dependsOn,
 47        dependedBy: dep.dependedBy,
 48        level: 0,
 49      })
 50    }
 51  
 52    // Calculate levels (simple BFS)
 53    const rootNodes = dependencies.filter((d) => d.dependsOn.length === 0)
 54    const queue = [...rootNodes.map((r) => r.prHash)]
 55    const visited = new Set<string>()
 56  
 57    while (queue.length > 0) {
 58      const current = queue.shift()!
 59      if (visited.has(current)) continue
 60      visited.add(current)
 61  
 62      const node = nodes.get(current)
 63      if (!node) continue
 64  
 65      for (const child of node.dependedBy) {
 66        const childNode = nodes.get(child)
 67        if (childNode) {
 68          childNode.level = Math.max(childNode.level, node.level + 1)
 69          queue.push(child)
 70        }
 71      }
 72    }
 73  
 74    // Group by level
 75    const levels: string[][] = []
 76    for (const [hash, node] of nodes) {
 77      if (!levels[node.level]) levels[node.level] = []
 78      levels[node.level].push(hash)
 79    }
 80  
 81    return (
 82      <div className="bg-white border rounded-lg p-4">
 83        <h4 className="font-medium mb-4">PR Dependency Graph</h4>
 84  
 85        <div className="space-y-4">
 86          {levels.map((levelNodes, level) => (
 87            <div key={level}>
 88              <div className="text-xs text-gray-500 mb-2">
 89                Level {level} {level === 0 ? '(Root PRs)' : ''}
 90              </div>
 91              <div className="flex flex-wrap gap-2">
 92                {levelNodes.map((prHash) => {
 93                  const node = nodes.get(prHash)!
 94                  const isHighlighted = prHash === highlightPrHash
 95                  const hasDependents = node.dependedBy.length > 0
 96  
 97                  return (
 98                    <div
 99                      key={prHash}
100                      className={`relative group ${
101                        isHighlighted
102                          ? 'ring-2 ring-blue-500 ring-offset-2 rounded-lg'
103                          : ''
104                      }`}
105                    >
106                      <div
107                        className={`px-3 py-2 rounded-lg text-xs font-mono ${
108                          hasDependents
109                            ? 'bg-amber-100 border border-amber-300'
110                            : 'bg-gray-100 border border-gray-300'
111                        }`}
112                      >
113                        {prHash.slice(0, 8)}...
114                        {hasDependents && (
115                          <span className="ml-2 text-amber-600">
116                            ({node.dependedBy.length} dependents)
117                          </span>
118                        )}
119                      </div>
120  
121                      {/* Tooltip */}
122                      <div className="absolute bottom-full left-0 mb-2 hidden group-hover:block z-10">
123                        <div className="bg-gray-900 text-white text-xs rounded-lg p-3 shadow-lg min-w-48">
124                          <div className="font-mono mb-2">{prHash}</div>
125                          {node.dependsOn.length > 0 && (
126                            <div className="mb-2">
127                              <div className="text-gray-400">Depends on:</div>
128                              {node.dependsOn.map((d) => (
129                                <div key={d} className="font-mono text-green-400">
130                                  {d.slice(0, 8)}...
131                                </div>
132                              ))}
133                            </div>
134                          )}
135                          {node.dependedBy.length > 0 && (
136                            <div>
137                              <div className="text-gray-400">Depended by:</div>
138                              {node.dependedBy.map((d) => (
139                                <div key={d} className="font-mono text-amber-400">
140                                  {d.slice(0, 8)}...
141                                </div>
142                              ))}
143                            </div>
144                          )}
145                        </div>
146                      </div>
147                    </div>
148                  )
149                })}
150              </div>
151            </div>
152          ))}
153        </div>
154  
155        {/* Legend */}
156        <div className="mt-6 pt-4 border-t flex gap-4 text-xs">
157          <div className="flex items-center gap-2">
158            <div className="w-4 h-4 bg-gray-100 border border-gray-300 rounded" />
159            <span className="text-gray-600">Leaf PR (no dependents)</span>
160          </div>
161          <div className="flex items-center gap-2">
162            <div className="w-4 h-4 bg-amber-100 border border-amber-300 rounded" />
163            <span className="text-gray-600">Has dependents (risky to rollback)</span>
164          </div>
165        </div>
166      </div>
167    )
168  }
169  
170  interface DependencyNode {
171    prHash: string
172    dependsOn: string[]
173    dependedBy: string[]
174    level: number
175  }
176  
177  /**
178   * Compact dependency indicator for PR cards
179   */
180  export function DependencyBadge({
181    dependsOn,
182    dependedBy,
183  }: {
184    dependsOn: number
185    dependedBy: number
186  }) {
187    if (dependsOn === 0 && dependedBy === 0) return null
188  
189    return (
190      <div className="flex items-center gap-2 text-xs">
191        {dependsOn > 0 && (
192          <span className="px-2 py-0.5 bg-green-100 text-green-700 rounded">
193            {dependsOn} deps
194          </span>
195        )}
196        {dependedBy > 0 && (
197          <span className="px-2 py-0.5 bg-amber-100 text-amber-700 rounded">
198            {dependedBy} dependents
199          </span>
200        )}
201      </div>
202    )
203  }