# HTML Publisher Report Links Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace broken console-text URL extraction with Jenkins HTML Publisher API to get correct report labels.

**Architecture:** Add two new methods to JenkinsClient (`getHtmlReports`, `getReportHtml`), a new `extractUrlsFromHtml` function, make `stageId` nullable in `extracted_urls`, and remove stage-based URL extraction from the poller. The client and API route drop `stageName` in favor of `label`.

**Tech Stack:** TypeScript, Drizzle ORM (SQLite), Express, React

**Spec:** `docs/superpowers/specs/2026-03-19-html-publisher-report-links-design.md`

---

## File Map

| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `server/poller/jenkins-client.ts` | Add `getHtmlReports()` and `getReportHtml()` |
| Create | `server/poller/html-report-extractor.ts` | `extractUrlsFromHtml(html, reportName)` |
| Modify | `server/db/schema.ts` | Make `stageId` nullable |
| Create | `server/db/migrations/0002_*.sql` | SQLite table recreation migration |
| Modify | `server/poller/index.ts` | Replace URL extraction with HTML Publisher flow |
| Modify | `server/routes/pipelines.ts` | `innerJoin` -> simple select, drop `stageName` |
| Modify | `server/config.ts` | Remove `urlPatterns` from Config |
| Modify | `server/index.ts` | Remove `urlPatterns` from `startPoller` call |
| Modify | `client/src/types.ts` | Remove `stageName` from `ExtractedUrl` |
| Modify | `client/src/components/PipelineCard.tsx` | Use `u.label` instead of `u.stageName` |
| Modify | `server/poller/jenkins-client.test.ts` | Add tests for new methods |
| Create | `server/poller/html-report-extractor.test.ts` | Tests for `extractUrlsFromHtml` |
| Modify | `server/poller/poller.test.ts` | Rewrite URL extraction test |
| Modify | `server/routes/pipelines.test.ts` | Update `extractedUrls` inserts to use `stageId: null` |

---

### Task 1: Add `getHtmlReports` and `getReportHtml` to JenkinsClient

**Files:**
- Modify: `server/poller/jenkins-client.ts`
- Modify: `server/poller/jenkins-client.test.ts`

- [ ] **Step 1: Write failing tests for `getHtmlReports`**

Add to `server/poller/jenkins-client.test.ts`:

```typescript
it("fetches HTML publisher reports from build actions", async () => {
  mockFetch.mockResolvedValueOnce({
    ok: true,
    json: async () => ({
      actions: [
        { _class: "htmlpublisher.HtmlPublisherTarget$HTMLBuildAction", reportName: "Playwright Report - QA-Default-Suite", urlName: "Playwright_20Report_20-_20QA-Default-Suite" },
        { _class: "htmlpublisher.HtmlPublisherTarget$HTMLBuildAction", reportName: "Playwright Report - QA-Permissions-Suite", urlName: "Playwright_20Report_20-_20QA-Permissions-Suite" },
        { _class: "hudson.model.CauseAction" },
      ],
    }),
  });

  const reports = await client.getHtmlReports("/job/repo/job/main", 42);
  expect(reports).toEqual([
    { reportName: "Playwright Report - QA-Default-Suite", urlName: "Playwright_20Report_20-_20QA-Default-Suite" },
    { reportName: "Playwright Report - QA-Permissions-Suite", urlName: "Playwright_20Report_20-_20QA-Permissions-Suite" },
  ]);
  expect(mockFetch).toHaveBeenCalledWith(
    "https://jenkins.example.com/job/repo/job/main/42/api/json?tree=actions[_class,urlName,reportName]",
    expect.any(Object)
  );
});

it("returns empty array when no HTML publisher actions", async () => {
  mockFetch.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ actions: [{ _class: "hudson.model.CauseAction" }] }),
  });

  const reports = await client.getHtmlReports("/job/repo/job/main", 42);
  expect(reports).toEqual([]);
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `rtk vitest run server/poller/jenkins-client.test.ts`
Expected: FAIL — `client.getHtmlReports is not a function`

- [ ] **Step 3: Write failing test for `getReportHtml`**

Add to `server/poller/jenkins-client.test.ts`:

```typescript
it("fetches report HTML page with text/html Accept header", async () => {
  mockFetch.mockResolvedValueOnce({
    ok: true,
    text: async () => '<html><body><a href="https://playwright.example.com/report">Report</a></body></html>',
  });

  const html = await client.getReportHtml("/job/repo/job/main", 42, "Playwright_20Report_20-_20QA-Default-Suite");
  expect(html).toContain("playwright.example.com");
  expect(mockFetch).toHaveBeenCalledWith(
    "https://jenkins.example.com/job/repo/job/main/42/Playwright_20Report_20-_20QA-Default-Suite/",
    expect.objectContaining({
      headers: expect.objectContaining({ Accept: "text/html" }),
    })
  );
});
```

- [ ] **Step 4: Run tests to verify it fails**

Run: `rtk vitest run server/poller/jenkins-client.test.ts`
Expected: FAIL — `client.getReportHtml is not a function`

- [ ] **Step 5: Implement `getHtmlReports` and `getReportHtml`**

In `server/poller/jenkins-client.ts`:

Add the `HtmlReport` interface after the existing interfaces:

```typescript
export interface HtmlReport {
  reportName: string;
  urlName: string;
}
```

Add an optional `accept` parameter to the private `request` method:

```typescript
private async request(path: string, accept: string = "application/json"): Promise<Response> {
  const url = path.startsWith("http") ? path : `${this.baseUrl}${path}`;
  const response = await fetch(url, {
    headers: {
      Authorization: this.authHeader,
      Accept: accept,
    },
  });

  if (!response.ok) {
    throw new Error(`Jenkins API error: ${response.status}`);
  }

  return response;
}
```

Add the two new methods to the class:

```typescript
async getHtmlReports(jobPath: string, buildNumber: number): Promise<HtmlReport[]> {
  const response = await this.request(`${jobPath}/${buildNumber}/api/json?tree=actions[_class,urlName,reportName]`);
  const data = await response.json() as { actions: { _class?: string; reportName?: string; urlName?: string }[] };
  return data.actions
    .filter((a) => a._class === "htmlpublisher.HtmlPublisherTarget$HTMLBuildAction" && a.reportName && a.urlName)
    .map((a) => ({ reportName: a.reportName!, urlName: a.urlName! }));
}

async getReportHtml(jobPath: string, buildNumber: number, urlName: string): Promise<string> {
  const response = await this.request(`${jobPath}/${buildNumber}/${urlName}/`, "text/html");
  return response.text();
}
```

- [ ] **Step 6: Run tests to verify they pass**

Run: `rtk vitest run server/poller/jenkins-client.test.ts`
Expected: All PASS

- [ ] **Step 7: Commit**

```bash
rtk git add server/poller/jenkins-client.ts server/poller/jenkins-client.test.ts && rtk git commit -m "feat: add getHtmlReports and getReportHtml to JenkinsClient"
```

---

### Task 2: Create `extractUrlsFromHtml` function

**Files:**
- Create: `server/poller/html-report-extractor.ts`
- Create: `server/poller/html-report-extractor.test.ts`

- [ ] **Step 1: Write failing tests**

Create `server/poller/html-report-extractor.test.ts`:

```typescript
import { describe, it, expect } from "vitest";
import { extractUrlsFromHtml } from "./html-report-extractor.js";

describe("extractUrlsFromHtml", () => {
  const sampleHtml = `<html><body>
    <a href="https://playwright.theorchard.io/api/bucket/prod/report/frontend-auth-main-QA-1958/merged-report">Playwright Report</a>
    <a href="https://app.datadoghq.com/ci/test-runs?query=test_level%3Asuite">Datadog Test Runs</a>
    <a href="#anchor">Skip this</a>
    <a href="/relative">Skip this too</a>
  </body></html>`;

  it("extracts http URLs from anchor tags", () => {
    const result = extractUrlsFromHtml(sampleHtml, "Playwright Report - QA-Default-Suite");
    expect(result).toHaveLength(2);
  });

  it("labels playwright URL with clean suite name", () => {
    const result = extractUrlsFromHtml(sampleHtml, "Playwright Report - QA-Default-Suite");
    const pw = result.find((u) => u.url.includes("playwright"));
    expect(pw?.label).toBe("QA-Default-Suite");
  });

  it("labels datadog URL with suite name + (Datadog)", () => {
    const result = extractUrlsFromHtml(sampleHtml, "Playwright Report - QA-Default-Suite");
    const dd = result.find((u) => u.url.includes("datadoghq"));
    expect(dd?.label).toBe("QA-Default-Suite (Datadog)");
  });

  it("ignores relative and anchor hrefs", () => {
    const result = extractUrlsFromHtml(sampleHtml, "Playwright Report - QA-Default-Suite");
    expect(result.every((u) => u.url.startsWith("http"))).toBe(true);
  });

  it("returns empty array when no anchor tags", () => {
    const result = extractUrlsFromHtml("<html><body>No links</body></html>", "Report - Suite");
    expect(result).toEqual([]);
  });

  it("uses full reportName as label when no ' - ' separator", () => {
    const html = '<a href="https://playwright.example.com/report">Report</a>';
    const result = extractUrlsFromHtml(html, "CustomReport");
    expect(result[0].label).toBe("CustomReport");
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `rtk vitest run server/poller/html-report-extractor.test.ts`
Expected: FAIL — cannot resolve module

- [ ] **Step 3: Implement `extractUrlsFromHtml`**

Create `server/poller/html-report-extractor.ts`:

```typescript
export interface ReportUrl {
  url: string;
  label: string;
}

export function extractUrlsFromHtml(html: string, reportName: string): ReportUrl[] {
  // Strip prefix up to and including " - " (e.g. "Playwright Report - QA-Default-Suite" -> "QA-Default-Suite")
  const dashIndex = reportName.indexOf(" - ");
  const suiteName = dashIndex >= 0 ? reportName.slice(dashIndex + 3) : reportName;

  const results: ReportUrl[] = [];
  const hrefRegex = /<a\s[^>]*href="(https?:\/\/[^"]+)"/gi;
  let match: RegExpExecArray | null;

  while ((match = hrefRegex.exec(html)) !== null) {
    const url = match[1];
    const label = url.includes("datadoghq.com") ? `${suiteName} (Datadog)` : suiteName;
    results.push({ url, label });
  }

  return results;
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `rtk vitest run server/poller/html-report-extractor.test.ts`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
rtk git add server/poller/html-report-extractor.ts server/poller/html-report-extractor.test.ts && rtk git commit -m "feat: add extractUrlsFromHtml for HTML Publisher reports"
```

---

### Task 3: Make `stageId` nullable in schema + migration

**Files:**
- Modify: `server/db/schema.ts:45-56`
- Create: `server/db/migrations/0002_*.sql` (generated by Drizzle Kit)
- Modify: `server/db/migrations/meta/_journal.json` (auto-updated by Drizzle Kit)
- Create: `server/db/migrations/meta/0002_snapshot.json` (auto-generated)

- [ ] **Step 1: Update schema to make `stageId` nullable**

In `server/db/schema.ts`, change `extracted_urls.stageId` (line 50-52) from:

```typescript
  stageId: integer("stage_id")
    .notNull()
    .references(() => stages.id, { onDelete: "cascade" }),
```

to:

```typescript
  stageId: integer("stage_id")
    .references(() => stages.id, { onDelete: "cascade" }),
```

(Just remove `.notNull()`)

- [ ] **Step 2: Generate the migration**

Run: `rtk npx drizzle-kit generate`

This should produce a migration in `server/db/migrations/`. Since SQLite doesn't support `ALTER COLUMN`, Drizzle will generate a table recreation migration (create new table, copy data, drop old, rename).

- [ ] **Step 3: Verify migration was generated correctly**

Read the generated migration SQL file and confirm it:
1. Creates a new `extracted_urls` table with `stage_id` without `NOT NULL`
2. Copies data from old table
3. Drops old table
4. Renames new table

- [ ] **Step 4: Run existing schema tests to verify migration applies cleanly**

Run: `rtk vitest run server/db/schema.test.ts`
Expected: PASS (migration applies to in-memory DB, existing cascade test still works)

- [ ] **Step 5: Add a test for nullable stageId insertion**

Add to `server/db/schema.test.ts` inside the `describe("schema")` block:

```typescript
it("allows extracted_urls with null stageId", () => {
  const pipeline = db
    .insert(schema.pipelines)
    .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/repo2" })
    .returning()
    .get();

  const run = db
    .insert(schema.runs)
    .values({ pipelineId: pipeline.id, buildNumber: 1, status: "red" })
    .returning()
    .get();

  db.insert(schema.extractedUrls)
    .values({ runId: run.id, stageId: null, url: "https://report.example.com", label: "QA-Suite" })
    .run();

  const urls = db.select().from(schema.extractedUrls).all();
  expect(urls).toHaveLength(1);
  expect(urls[0].stageId).toBeNull();
  expect(urls[0].label).toBe("QA-Suite");
});
```

- [ ] **Step 6: Run schema tests to verify nullable stageId works**

Run: `rtk vitest run server/db/schema.test.ts`
Expected: All PASS

- [ ] **Step 7: Commit**

```bash
rtk git add server/db/schema.ts server/db/schema.test.ts server/db/migrations/ && rtk git commit -m "feat: make extracted_urls.stageId nullable"
```

---

### Task 4: Replace URL extraction in poller

**Files:**
- Modify: `server/poller/index.ts:1-5,43-48,101,115,120-203,258-264`
- Modify: `server/poller/poller.test.ts:142-172`

- [ ] **Step 1: Update `createMockJenkinsClient` in test**

In `server/poller/poller.test.ts`, update `createMockJenkinsClient` (line 19-27) to include new method stubs:

```typescript
function createMockJenkinsClient(overrides: Partial<JenkinsClient> = {}): JenkinsClient {
  return {
    getRuns: vi.fn().mockResolvedValue([]),
    getRunDetail: vi.fn().mockResolvedValue({ stages: [] }),
    getConsoleText: vi.fn().mockResolvedValue(""),
    getStageLog: vi.fn().mockResolvedValue(""),
    getHtmlReports: vi.fn().mockResolvedValue([]),
    getReportHtml: vi.fn().mockResolvedValue(""),
    ...overrides,
  } as unknown as JenkinsClient;
}
```

- [ ] **Step 2: Rewrite the URL extraction test**

Replace the test at line 142-172 with:

```typescript
it("fetches HTML publisher reports and extracts URLs for red runs", async () => {
  const pipeline = db
    .insert(schema.pipelines)
    .values({ name: "repo/main", jenkinsUrl: "/job/repo/job/main" })
    .returning()
    .get();

  const client = createMockJenkinsClient({
    getRuns: vi.fn().mockResolvedValue([
      { id: "11", name: "#11", status: "FAILURE", startTimeMillis: 1710600000000, durationMillis: 120000 },
    ]),
    getRunDetail: vi.fn().mockResolvedValue({
      stages: [
        { id: 6, name: "Build", status: "SUCCESS", durationMillis: 30000 },
        { id: 7, name: "E2E Tests", status: "FAILURE", durationMillis: 90000 },
      ],
    }),
    getHtmlReports: vi.fn().mockResolvedValue([
      { reportName: "Playwright Report - QA-Default-Suite", urlName: "Playwright_20Report_20-_20QA-Default-Suite" },
    ]),
    getReportHtml: vi.fn().mockResolvedValue(
      '<a href="https://playwright.example.com/report/11">Report</a><a href="https://app.datadoghq.com/ci/test-runs?q=test">DD</a>'
    ),
  });

  await pollPipeline(db, client, pipeline, "e2e");

  const urls = db.select().from(schema.extractedUrls).all();
  expect(urls).toHaveLength(2);
  expect(urls[0].url).toBe("https://playwright.example.com/report/11");
  expect(urls[0].label).toBe("QA-Default-Suite");
  expect(urls[0].stageId).toBeNull();
  expect(urls[1].label).toBe("QA-Default-Suite (Datadog)");

  expect(client.getHtmlReports).toHaveBeenCalledWith("/job/repo/job/main", 11);
  expect(client.getReportHtml).toHaveBeenCalledWith("/job/repo/job/main", 11, "Playwright_20Report_20-_20QA-Default-Suite");
});
```

- [ ] **Step 3: Update other `pollPipeline` calls in tests to drop `urlPatterns` arg**

In `server/poller/poller.test.ts`, change all `pollPipeline(db, client, pipeline, "playwrighttest", [])` calls (lines 107, 136) to `pollPipeline(db, client, pipeline, "playwrighttest")`, and the new test uses `pollPipeline(db, client, pipeline, "e2e")`.

- [ ] **Step 4: Run tests to verify they fail**

Run: `rtk vitest run server/poller/poller.test.ts`
Expected: FAIL — `pollPipeline` still expects 5 args, `getHtmlReports` not called

- [ ] **Step 5: Implement poller changes**

In `server/poller/index.ts`:

**Remove** the `extractUrls` import (line 5).

**Add** new imports at top:

```typescript
import { extractUrlsFromHtml } from "./html-report-extractor.js";
```

**Update `pollPipeline` signature** (line 43-48) — remove `urlPatterns` parameter:

```typescript
export async function pollPipeline(
  db: DB,
  client: JenkinsClient,
  pipeline: typeof schema.pipelines.$inferSelect,
  stagePattern: string
): Promise<void> {
```

**Update calls to `insertStagesAndUrls`** (lines 101, 115) — remove `urlPatterns` arg:

```typescript
await insertStagesAndUrls(db, client, runId, pipeline.jenkinsUrl, buildNumber, detail.stages, stagePattern, status);
```

**Rewrite `insertStagesAndUrls`** (line 120-203):

```typescript
async function insertStagesAndUrls(
  db: DB,
  client: JenkinsClient,
  runId: number,
  jenkinsUrl: string,
  buildNumber: number,
  jenkinsStages: JenkinsStage[],
  stagePattern: string,
  status: string
): Promise<void> {
  const pattern = stagePattern.toLowerCase();

  for (const s of jenkinsStages) {
    const isPlaywright = s.name.toLowerCase().includes(pattern) ? 1 : 0;

    db.insert(schema.stages)
      .values({
        runId,
        name: s.name,
        status: s.status,
        durationMs: s.durationMillis,
        isPlaywright,
      })
      .run();
  }

  // Only fetch HTML reports for red builds
  if (status === "red") {
    try {
      const reports = await client.getHtmlReports(jenkinsUrl, buildNumber);

      for (const report of reports) {
        try {
          const html = await client.getReportHtml(jenkinsUrl, buildNumber, report.urlName);
          const urls = extractUrlsFromHtml(html, report.reportName);

          for (const u of urls) {
            db.insert(schema.extractedUrls)
              .values({ runId, stageId: null, url: u.url, label: u.label })
              .run();
          }
        } catch {
          // Single report fetch failed — skip it, continue with others
        }
      }
    } catch {
      // getHtmlReports failed — skip URL extraction for this build
    }
  }
}
```

**Update `startPoller` signature** (line 258-264) — remove `urlPatterns`:

```typescript
export function startPoller(
  db: DB,
  client: JenkinsClient,
  stagePattern: string,
  intervalSeconds: number
): Poller {
```

**Update `pollPipeline` call inside `startPoller`** (line 280):

```typescript
await pollPipeline(db, client, p, stagePattern);
```

- [ ] **Step 6: Run poller tests**

Run: `rtk vitest run server/poller/poller.test.ts`
Expected: All PASS

- [ ] **Step 7: Commit**

```bash
rtk git add server/poller/index.ts server/poller/poller.test.ts && rtk git commit -m "feat: replace console URL extraction with HTML Publisher reports"
```

---

### Task 5: Update config and server entry point

**Files:**
- Modify: `server/config.ts:5-22`
- Modify: `server/index.ts:22-28`

- [ ] **Step 1: Remove `urlPatterns` from Config interface**

In `server/config.ts`, change the `playwright` section (lines 14-18) from:

```typescript
  playwright: {
    stagePattern: string;
    jenkinsfilePattern: string;
    urlPatterns: string[];
  };
```

to:

```typescript
  playwright: {
    stagePattern: string;
    jenkinsfilePattern: string;
  };
```

- [ ] **Step 2: Remove `urlPatterns` from `config.json`**

In `config.json`, change the `playwright` section from:

```json
  "playwright": {
    "stagePattern": "E2E",
    "jenkinsfilePattern": "playwrightTest",
    "urlPatterns": ["Playwright Report Link:\\s*(https?://\\S+)"]
  },
```

to:

```json
  "playwright": {
    "stagePattern": "E2E",
    "jenkinsfilePattern": "playwrightTest"
  },
```

- [ ] **Step 3: Update `startPoller` call in `server/index.ts`**

Change lines 22-28 from:

```typescript
const poller = startPoller(
  db,
  client,
  config.playwright.stagePattern.toLowerCase(),
  config.playwright.urlPatterns,
  config.polling.intervalSeconds
);
```

to:

```typescript
const poller = startPoller(
  db,
  client,
  config.playwright.stagePattern.toLowerCase(),
  config.polling.intervalSeconds
);
```

- [ ] **Step 4: Run full test suite to confirm nothing breaks**

Run: `rtk vitest run`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
rtk git add server/config.ts server/index.ts config.json && rtk git commit -m "chore: remove urlPatterns from config and startPoller"
```

---

### Task 6: Update API route — drop `stageName`, fix join

**Files:**
- Modify: `server/routes/pipelines.ts:7-18,96-106`
- Modify: `server/routes/pipelines.test.ts:43-45`

- [ ] **Step 1: Update the test to use `stageId: null`**

In `server/routes/pipelines.test.ts`, change line 43-45 from:

```typescript
    db.insert(schema.extractedUrls)
      .values({ runId: run.id, stageId: stage.id, url: "https://report.example.com", label: "Playwright Report Link" })
      .run();
```

to:

```typescript
    db.insert(schema.extractedUrls)
      .values({ runId: run.id, stageId: null, url: "https://report.example.com", label: "QA-Default-Suite" })
      .run();
```

- [ ] **Step 2: Run test to verify it fails**

Run: `rtk vitest run server/routes/pipelines.test.ts`
Expected: FAIL — `innerJoin` returns no rows when `stageId` is null

- [ ] **Step 3: Update `PipelineResponse` interface**

In `server/routes/pipelines.ts`, change line 18 from:

```typescript
    extractedUrls: { url: string; label: string | null; stageName: string }[];
```

to:

```typescript
    extractedUrls: { url: string; label: string | null }[];
```

- [ ] **Step 4: Replace `innerJoin` with direct query**

In `server/routes/pipelines.ts`, change lines 97-106 from:

```typescript
    const urls = db
      .select({
        url: schema.extractedUrls.url,
        label: schema.extractedUrls.label,
        stageName: schema.stages.name,
      })
      .from(schema.extractedUrls)
      .innerJoin(schema.stages, eq(schema.extractedUrls.stageId, schema.stages.id))
      .where(eq(schema.extractedUrls.runId, latestRun.id))
      .all();
```

to:

```typescript
    const urls = db
      .select({
        url: schema.extractedUrls.url,
        label: schema.extractedUrls.label,
      })
      .from(schema.extractedUrls)
      .where(eq(schema.extractedUrls.runId, latestRun.id))
      .all();
```

- [ ] **Step 5: Run pipelines tests**

Run: `rtk vitest run server/routes/pipelines.test.ts`
Expected: All PASS

- [ ] **Step 6: Commit**

```bash
rtk git add server/routes/pipelines.ts server/routes/pipelines.test.ts && rtk git commit -m "feat: drop stageName from API, query extractedUrls directly"
```

---

### Task 7: Update client types and rendering

**Files:**
- Modify: `client/src/types.ts:1-5`
- Modify: `client/src/components/PipelineCard.tsx:122`

- [ ] **Step 1: Remove `stageName` from `ExtractedUrl` interface**

In `client/src/types.ts`, change lines 1-5 from:

```typescript
export interface ExtractedUrl {
  url: string;
  label: string | null;
  stageName: string;
}
```

to:

```typescript
export interface ExtractedUrl {
  url: string;
  label: string | null;
}
```

- [ ] **Step 2: Update `PipelineCard.tsx` label rendering**

In `client/src/components/PipelineCard.tsx`, change line 122 from:

```tsx
              ↗ {u.stageName || u.label || "Report"}
```

to:

```tsx
              ↗ {u.label || "Report"}
```

- [ ] **Step 3: Run full test suite**

Run: `rtk vitest run`
Expected: All PASS

- [ ] **Step 4: Commit**

```bash
rtk git add client/src/types.ts client/src/components/PipelineCard.tsx && rtk git commit -m "feat: use label for report link pills, remove stageName"
```

---

### Task 8: Final verification

- [ ] **Step 1: Run full test suite one more time**

Run: `rtk vitest run`
Expected: All PASS

- [ ] **Step 2: TypeScript check**

Run: `rtk tsc --noEmit`
Expected: No errors

- [ ] **Step 3: Verify no unused imports**

Check that `server/poller/index.ts` no longer imports `extractUrls` from `url-extractor.js`. The `url-extractor.ts` file and its tests are kept (per spec) but no longer imported by the poller.
