# Hoot Lens for GitHub

Hoot Lens for GitHub is a GitHub App that closes the loop between what visitors did and what your coding agent ships:

1. Hoot Lens opens a GitHub issue for something it observed (a pile-up, no-show or misfire on an element, or a goal whose conversion rate fell), with the evidence and a prompt a coding agent can follow.
2. Your team assigns the issue to a coding agent (see [Assign an issue to a coding agent](#assign-an-issue-to-a-coding-agent)).
3. The agent connects to Hoot Lens over [MCP](API.md#oauth-and-the-hosted-mcp-endpoint), reads the evidence, and opens a pull request.
4. When the pull request merges and its change is logged, Hoot Lens comments on it with the "Did it help?" result, in the wording the API gives, once enough visits followed.

It is part of Hoot Lens, not of any customer site. It never reads or writes code, and it only sees the repositories you give it.

## What it does and does not do

- **Issues.** For each linked site, the job takes the strongest findings and opens at most N issues per week (setting, default 3, up to 10), one per distinct element and page. A finding is an element with at least 10 snags (pile-ups, no-shows and misfires together) that make up at least 5% of the clicks on it, in the newest release, revision and variant of its page, with device classes of that version added together. Cohorts of different versions are never merged. A goal drop is the alert rule's own: the recent conversion rate is clearly below the 14 day baseline, with at least 100 visits each.
- **No duplicates.** Each issue body carries a hidden comment (`hootlens:fingerprint=...`) and Hoot Lens records the same fingerprint, a hash of the site, page group and element (or goal). A finding with an open issue is skipped. A finding whose issue was closed is skipped for 30 days. People closing or reopening an issue on GitHub is picked up from the `issues` webhook.
- **Closing.** If the current version of a page has no longer shown a finding for 14 days, Hoot Lens comments and closes the issue (setting, on by default). It never closes an issue a person opened.
- **Pull request comments.** On a merge into the default branch of a linked repository, Hoot Lens remembers the pull request number, the merge commit and the merge time. It matches that to a change marker: the marker whose `release` or `note` names the merge commit (the full SHA or an abbreviation of at least 7 characters, which is what a deploy hook or the GitHub Action logs), otherwise the earliest change marker a person or agent logged from 5 minutes before to 24 hours after the merge. Markers detected automatically from page content are never matched by time, and one marker belongs to one pull request. A merge with no marker after 3 days is left alone. Once the change's report has enough visits on both sides it posts one comment and updates it in place when the verdict changes. The sentence is the impact report's own summary, copied as is, followed by the report's caveats and the visit counts. Hoot Lens adds no interpretation.
- **What goes into an issue.** Counts, the page group name, the element's selector and `data-hoot-id`, and the element's name as the dashboard shows it. Under the strict privacy preset no element label is used. Labels are cleaned of markup, links, mentions and issue references before use. Never visitor text, emails or page text.
- **Evidence links.** Up to two recordings opened at the moment of the snag (these need a Hoot Lens sign-in) and a heatmap link. For a **private** repository, Hoot Lens also makes up to two 30 day share links to those recordings, made as the person who linked the repository (their role is checked again each time a link is opened, so the links stop if they leave). For a **public** repository no share link is ever written, because anyone could open it.

Observed behavior only: an issue says what visitors did, not why.

## Set it up (owner of the Hoot Lens deployment)

Do this once per Hoot Lens environment. The secrets and the Cloud Run wiring are in [GCP setup](GCP-SETUP.md#github-app-secrets); rotation is in [Operations](OPERATIONS.md#rotate-the-github-app-secrets).

1. Create the GitHub App (GitHub, Settings, Developer settings, GitHub Apps, New GitHub App), owned by Parallel Platforms:
   - **Homepage URL** `https://hootlens.com`.
   - **Callback URL** `https://hootlens.com/app/settings` (`http://localhost:5173/app/settings` for local work, as a second callback URL). GitHub returns people here after they sign in.
   - **Expire user authorization tokens**: on. **Request user authorization (OAuth) during installation**: off. Hoot Lens starts the sign-in itself from Settings, GitHub.
   - **Webhook**: active, URL `https://api.hootlens.com/v1/github/webhook` (staging: its own API origin), content type `application/json`, and a random secret (see the secrets page).
   - **Repository permissions**: Issues: Read and write. Pull requests: Read-only. Metadata: Read-only (always on). Nothing else.
   - **Subscribe to events**: Pull request, Issues. The `installation` and `installation_repositories` events are always delivered and need no subscription.
   - **Where can this app be installed**: any account, if customers will install it; otherwise only on your account.
2. Generate a **private key** (a `.pem`) and a **client secret**. Note the **Client ID**, and the app's URL name (`github.com/apps/<name>`).
3. Put these in Secret Manager and give them to the API service and the alerts job as `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_WEBHOOK_SECRET` and the plain value `GITHUB_APP_SLUG`. Without all of them the feature stays off and Settings says so.

The App's JWT uses the client ID as its issuer, as GitHub's documentation recommends. `GITHUB_APP_ID` (the numeric id) is accepted as the issuer when no client ID is set.

## Connect a site (owner or editor)

Settings, GitHub, **Connect GitHub**. The person signs in to GitHub, then Hoot Lens lists the installations of the App that this GitHub account can reach, and the repositories each can see. If the App is not installed yet, the page links to GitHub's install screen. Choose a repository and **Link repository**. Then choose what to turn on:

| Setting | Default | |
| --- | --- | --- |
| Open issues for findings | on | |
| Most new issues per week | 3 | 1 to 10 |
| Add labels | on | `hootlens` and `hootlens:pile-up`, `hootlens:no-show`, `hootlens:misfire` or `hootlens:goal-drop`, created in the repository |
| Close issues whose finding has cleared | on | after 14 days |
| Comment on merged pull requests | on | |

The panel shows when the last check ran, the last issue and the count of open issues. A link pauses itself when the App is uninstalled or suspended or loses the repository, and says why. **Disconnect** stops everything; issues and comments already on GitHub stay.

Viewers do not see the section, and an access token or OAuth grant cannot read or change the connection: only a signed-in owner or editor can.

### Why the sign-in step

An installation id is public information, so naming one proves nothing. Without a check, anyone with editor access could point their site at someone else's installation and open issues in a repository they do not control. The check: Hoot Lens exchanges the sign-in code for a user token, lists `GET /user/installations` (the installations of this App the person can reach), discards the token, and signs a 30 minute grant for exactly those installation ids, bound to the person and the site. Linking needs that grant, and the repository must be one the installation can access. The state that travels through GitHub is signed, expires in an hour and is bound to the person and the site.

## How it runs

The job is the **GitHub step of the alerts job** (`npm run notify -- alerts`, every 15 minutes) when the GitHub App secrets are present. For each active link it: takes a lease (a marker per site, so two runs never file the same issue twice), reads the site's findings, closes issues whose finding cleared, opens new issues within the weekly cap, then handles merged pull requests. Failures are written on the link (`lastError`, shown in Settings), logged as `github_run` lines with counts, and fail the execution so the existing failed-execution alert fires. They never stop the alert emails.

Installation access tokens are minted on demand: a JWT (RS256, `iat` 60 seconds in the past, `exp` 9 minutes ahead, signed with the private key using `node:crypto`) is exchanged at `POST /app/installations/{id}/access_tokens`. A token lives an hour; Hoot Lens keeps it in memory only, reuses it until 5 minutes before it expires, and never stores or logs it. Requests are retried at most three times on network errors, 5xx and rate limits, honour `Retry-After`, and stop for the rest of the run when GitHub says the primary limit is spent.

## Assign an issue to a coding agent

Each issue has a section **Prompt for a coding agent**. The agent needs access to Hoot Lens over MCP: see [API and agent access](API.md#oauth-and-the-hosted-mcp-endpoint) and the AI assistant page. What each agent supports today, from its own documentation:

- **GitHub Copilot cloud agent** (previously called the coding agent). You can assign an issue to Copilot on GitHub.com, GitHub Mobile, through the GitHub API or with the GitHub CLI. It needs a paid Copilot plan (Pro, Pro+, Business or Enterprise), and on Business and Enterprise an administrator must have enabled it. Assign the issue to **Copilot** in the issue's Assignees list. Copilot then works on the issue and opens a pull request. The prompt asks the agent to use Hoot Lens over MCP; how to give Copilot an MCP server is in GitHub's documentation and was not checked here, so without it the agent still has the counts and links in the issue but not the tools.
- **Claude Code.** The Claude Code GitHub Action responds when someone mentions `@claude` in an issue or pull request comment (or in a newly opened issue's title or body), and the person who mentions it needs write access. The Action rejects bot actors unless they are listed in `allowed_bots`, so an issue opened by the Hoot Lens App will not start it by itself: a team member comments `@claude implement this issue` and Claude opens the pull request. Set up with `/install-github-app` from Claude Code, or manually (the Claude GitHub App, an `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` secret and a workflow file).
- **Codex.** OpenAI's documentation describes the GitHub integration as responding to `@codex` mentions in **pull request** comments, not issue comments. So there is no issue assignment: open Codex (cloud or CLI), connect the repository, paste the issue's prompt block or the issue link, and let it open the pull request. Codex can then be asked to review that pull request with `@codex review`.

Whichever you use: review the pull request before merging, and keep the merge commit message or the change marker's release naming the commit so Hoot Lens can match the merge to its change.

These descriptions were checked against GitHub, Anthropic and OpenAI documentation on October 7, 2026. Agent products change; confirm in their documentation if a step differs.

## Privacy and limits

- The App has Issues write and Pull requests read access, and the webhook delivers pull request, issue and installation events. Hoot Lens stores only: the repository (owner/name and id), the installation id, the person who linked it, settings, issue numbers it opened with their fingerprint and state, and for merged pull requests the number, merge commit SHA and merge time. It does not read or store pull request titles, bodies, branch names, authors, diffs or file names.
- Share links in issues exist only for private repositories and expire after 30 days. A project can hold 100 active share links; if that is reached, issues are written without them.
- Findings are aggregated counts over the cohorts Hoot Lens keeps, so an element can have snags and no example recording; the issue then has no recording links.
- Issues are created through GitHub's REST API, which can apply secondary rate limits to rapid content creation. The weekly cap is far under any such limit, and a limit hit pauses that run.
- This has not been run against a real GitHub App or repository by the code's tests. The tests use a mocked GitHub. Do the [first run check](#first-run-check) on a staging repository before relying on it.

## First run check

1. In staging, create the App, add the secrets and redeploy the API.
2. Settings, GitHub on a staging site with real recorded snags: Connect GitHub, link a throwaway repository, set the weekly limit to 1.
3. Run `gcloud run jobs execute hootlens-alerts --wait` and read the `github_run` line: `issuesOpened` should be 1. Open the issue, confirm the labels, the evidence links and the prompt, and that no visitor text appears.
4. Run it again: `issuesOpened` is 0.
5. Merge a pull request that your deploy hook or `log_change` records against that commit; once the report has enough visits, confirm the comment appears and updates in place.
