DEV Community

Alex Neamtu
Alex Neamtu

Posted on • Originally published at sendrec.eu

How We Added Jira and GitHub Integrations to SendRec

You record a bug. You watch it back. You open Jira, create a ticket, paste the video link, copy the transcript, and fill in the title. That's four context switches for something that should take one click.

SendRec now has built-in Jira and GitHub integrations. From any video, click "Create Issue" and a ticket appears in your project tracker with the video link and a transcript excerpt already attached.

Here's how we built it.

The IssueCreator interface

We wanted to support multiple providers without the handler knowing which one it's talking to. A simple interface does the job:

type IssueCreator interface {
    CreateIssue(ctx context.Context, req CreateIssueRequest) (*CreateIssueResponse, error)
    ValidateConfig(ctx context.Context) error
}

type CreateIssueRequest struct {
    Title       string
    Description string
    VideoURL    string
}
Enter fullscreen mode Exit fullscreen mode

CreateIssue files the ticket. ValidateConfig checks credentials before you save — so you find out immediately if your token is wrong, not when you try to file your first bug.

Adding a new provider means implementing two methods. The handler, the settings UI, and the config storage don't change.

GitHub: Bearer token and markdown

GitHub Issues have a straightforward API. POST to /repos/{owner}/{repo}/issues with a Bearer token:

func (c *GitHubClient) CreateIssue(ctx context.Context, req CreateIssueRequest) (*CreateIssueResponse, error) {
    body := map[string]any{
        "title": req.Title,
        "body":  formatGitHubBody(req),
    }
    bodyJSON, _ := json.Marshal(body)

    url := fmt.Sprintf("%s/repos/%s/%s/issues", c.baseURL, c.owner, c.repo)
    httpReq, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyJSON))
    httpReq.Header.Set("Authorization", "Bearer "+c.token)
    httpReq.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(httpReq)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The issue body is markdown with the video link prominent and the transcript in a collapsible <details> block so it doesn't overwhelm the ticket:

func formatGitHubBody(req CreateIssueRequest) string {
    body := fmt.Sprintf("**Video:** %s\n\n", req.VideoURL)
    if req.Description != "" {
        body += "<details>\n<summary>Transcript</summary>\n\n"
        body += req.Description
        body += "\n\n</details>"
    }
    return body
}
Enter fullscreen mode Exit fullscreen mode

ValidateConfig hits two endpoints: GET /user to verify the token works, then GET /repos/{owner}/{repo} to confirm the repo exists and the token has access.

Jira: Basic auth and Atlassian Document Format

Jira Cloud uses Basic auth (email + API token, base64-encoded) and requires the Atlassian Document Format (ADF) instead of plain text or markdown. ADF is a JSON tree that describes rich content:

func buildADFDescription(req CreateIssueRequest) map[string]any {
    content := []any{adfParagraph(adfInlineCard(req.VideoURL))}
    if req.Description != "" {
        content = append(content,
            adfParagraph(adfText("Transcript:")),
            adfCodeBlock(req.Description),
        )
    }
    return map[string]any{"version": 1, "type": "doc", "content": content}
}
Enter fullscreen mode Exit fullscreen mode

The video URL renders as an inline card in Jira — Atlassian automatically fetches the page metadata and shows a rich preview. The transcript goes in a code block to preserve formatting.

ValidateConfig calls GET /rest/api/3/myself — the simplest authenticated endpoint Jira offers. If it returns 200, your credentials work.

Encrypting API tokens at rest

Integration configs live in a user_integrations table as JSONB. Storing API tokens in plaintext would be reckless, even in a self-hosted database. We encrypt token fields with AES-256-GCM before writing them:

func Encrypt(key []byte, plaintext string) (string, error) {
    block, _ := aes.NewCipher(key)
    gcm, _ := cipher.NewGCM(block)

    nonce := make([]byte, gcm.NonceSize())
    io.ReadFull(rand.Reader, nonce)

    sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
    return base64.StdEncoding.EncodeToString(sealed), nil
}
Enter fullscreen mode Exit fullscreen mode

The encryption key is derived from the existing JWT_SECRET environment variable via SHA-256 — no new secrets to configure. Each encryption uses a random nonce, so the same token produces different ciphertexts each time.

When listing integrations, we decrypt the token, then mask it (show first 4 characters, replace the rest with asterisks). The full token never leaves the server after initial save.

One subtlety: when a user edits their config (say, changing the repo name) without re-entering the token, the frontend sends the masked value back. The backend detects this, loads the existing encrypted token from the database, and preserves it. No accidental token loss.

The settings UI

Each provider gets a collapsible card in Settings. Connected providers show a green "Connected" badge. Expand the card to see the config fields, pre-filled from saved data (with tokens masked).

Three buttons per provider:

  • Save — validates all required fields, encrypts tokens, upserts the config
  • Test Connection — calls ValidateConfig against the live API
  • Disconnect — deletes the config entirely

Creating issues from videos

On the VideoDetail page, a "Create Issue" button appears if you have at least one integration configured. If you have both GitHub and Jira connected, it becomes a dropdown so you can pick which tracker to file in.

Clicking it sends the video's title, share URL, and a transcript excerpt (first 500 characters) to the provider. The response includes the issue URL, which opens in a new tab.

What we learned

Keep the provider interface minimal. Two methods — CreateIssue and ValidateConfig — are enough. We were tempted to add ListProjects, GetIssueTypes, and other discovery methods, but that would couple the UI to each provider's data model. A flat config with required fields (token, owner, repo for GitHub; base URL, email, API token, project key for Jira) is simpler and works.

Encrypt at the boundary. Token encryption happens in the handler, not in the provider clients. The GitHub and Jira clients receive plaintext tokens and don't know encryption exists. This keeps the providers testable with simple string arguments.

Test against real API shapes. Both providers have test suites using httptest.Server that return realistic response payloads. This caught a bug early — Jira's create-issue response uses key (like PROJ-123), not id, for the issue identifier.

Try it

The integrations are live at app.sendrec.eu. Go to Settings, expand GitHub or Jira, enter your credentials, and hit Test Connection. Then open any video and click Create Issue.

Self-hosting? Pull the latest image and the migration runs automatically. No new environment variables needed — encryption uses your existing JWT_SECRET.

Source code: github.com/sendrec/sendrec

Top comments (0)