Skip to content

File and watch issues

Use this when a tool needs to raise an issue on someone's behalf: an escalation from a support channel, a bot filing a detected problem. It then follows what happens to it.

Two capabilities serve it, and they are separate on purpose.

This is the only write to somebody's project

Everything else in forge that touches a project reads. A mistake in a read is an inconvenience; a mistake here is a public artefact in a tracker you may not control, and it cannot be quietly undone.

(KeyManager.UploadKey also writes, but to the authenticated account rather than a project.)

Which backends implement this

Issues and IssueFiler are defined in forge v0.5.0 and implemented by forge-github, forge-gitlab and forge-gitea from v0.5.1. forge-bitbucket implements neither, deliberately. Check the capability matrix.

Reading needs a weaker token than filing

Operation GitLab role Token scope
SearchIssues, GetIssue, ListComments Guest read_api
CreateIssue Guest api

That is why they are two interfaces. A deployment trusted to watch and search but not to write declines IssueFiler and finds out at startup, rather than on its first attempt to file:

var issues forge.Issues
if !forge.As(provider, &issues) {
    return errNoIssues
}

var filer forge.IssueFiler
canFile := forge.As(provider, &filer)

Check for duplicates before filing

var existing []forge.Issue

err := issues.SearchIssues(ctx, owner, repo, forge.IssueQuery{
    Text:  question,
    State: forge.IssueStateOpen,
}, func(i forge.Issue) bool {
    existing = append(existing, i)

    return len(existing) < 10
})
if err != nil {
    return err // NOT "no duplicates found"
}

The error is not optional, and this is the place it matters most. A search that fails and is read as "nothing matched" causes you to file a duplicate into a public tracker. An empty result set and a failed search are different answers.

IssueQuery.Text is a filter, not a relevance ranking. Providers use server-side search where the forge offers it and filter client-side where it does not, so match quality varies and no ordering is promised. Deciding whether a match is a credible duplicate is yours.

File

issue, err := filer.CreateIssue(ctx, owner, repo, forge.IssueDraft{
    Title:          title,
    Body:           body,
    Labels:         []string{"support", "from-chat"},
    IdempotencyKey: requestID,
})
if err != nil && !errors.Is(err, forge.ErrNotHonoured) {
    return err // a real failure; nothing was filed
}

post(issue.URL) // Number and URL are what you need afterwards

Keep issue.Number if you intend to follow the issue up, and issue.URL for whoever you filed it for.

Labels must already exist, and matching is exact

The error check above is not the usual one, and that is because of the labels.

CreateIssue can return an issue and an error. When a label could not be applied it returns the filed issue together with an error wrapping ErrNotHonoured, so a bare if err != nil { return err } treats a filed report as a failure. If you set no labels you will never see this and the plain check is fine.

Two rules follow from that, and both are the same ones PullRequestDraft.Labels carries:

Every name must already exist on the repository. Setting a label never creates one, even on GitLab and GitHub where the API would. This capability files into a project you may not own, so creating a label as a side effect would turn your typo into a permanent artefact on somebody else's tracker.

Matching is exact. Support does not resolve to a support label — it resolves to nothing, and comes back in the hint. That is deliberate: a case-insensitive match would file your report against a label somebody else wrote and tell you it worked.

An unresolvable name does not lose the report. The issue is filed and the loss is named, because a report exists to be filed and the label is the decoration.

Set an IdempotencyKey if you might retry

Mishandling the sentinel costs less here than elsewhere, but only because of at-most-once filing: a retry finds the existing issue instead of filing a duplicate. Without a key, the retry files a second report.

Filing twice is the failure worth designing against

If a create succeeds but the response is lost in transit, a naive retry files the same thing again, visible to everyone watching the project, and not undoable.

IdempotencyKey prevents that: the provider looks for an existing issue carrying the key and returns it rather than filing a second.

Three limits, stated plainly:

  • It is not atomic. Two concurrent creates with the same key can both find nothing and both file. It defends against a retry, which is the case that actually happens, not against concurrency.
  • The key is published. It is written into the issue body as a visible trailer, so anyone who can read the issue can read it. Use an opaque value derived for the purpose, never a session token or a user identifier.
  • It costs one extra search per create. If you are already searching for duplicates, that is nearly free.

What gets sanitised, and what you must still do

CreateIssue runs Title and Body through forge.Sanitise before sending. see why writing changes the calculus for the reasoning. Two things are removed:

  • Secrets, via redact.String: URL credentials, auth headers, JWTs, cloud keys and long opaque tokens.
  • @-mentions, via forge.InertMentions: @someone becomes `someone`.

Both matter because the harm lands on someone who is not you. A credential pasted into a support question belongs to whoever pasted it. An activated handle belongs to whoever owns that name, and a handle from one system is rarely the same person on a forge, so a collision notifies a stranger on every reference to the issue.

The escape hatch, and when it is right

draft.Unsanitised = true

redact.String is calibrated for log lines rather than prose. It rewrites any run of 41 or more token characters, so a SHA-256 digest survives as <redacted-token> while a 40-character git SHA-1 passes through. In a bug report that can matter.

Use Unsanitised when you have sanitised under a policy of your own, or when the default would corrupt the report. Then it is on you:

if forge.HasLiveMention(body) {
    return errUnsafeBody
}

Sanitise anything else you write to a forge

CreateIssue covers itself. Anything else you compose (a commit message, an MR description) should go through the same helper:

body = forge.Sanitise(body)

It is idempotent, so applying it twice is harmless. That property is what makes the automatic application safe, and it means you never have to track whether it already ran.

Watch what happens next

issue, err := issues.GetIssue(ctx, owner, repo, number)
switch {
case errors.Is(err, forge.ErrNotFound):
    return errGone // deleted, moved, or never existed
case err != nil:
    return err // could not check — retry, do not conclude anything
}

if issue.State == forge.IssueStateClosed {
    notify(issue.ClosedAt, issue.ClosedBy)
}

Never treat IssueStateUnknown as open or closed. It means the provider could not determine the state, and acting on it either strands the person waiting or tells them their question was resolved when nothing happened.

Reading comments

err := issues.ListComments(ctx, owner, repo, number, forge.CommentQuery{
    Since: lastSeen,
}, func(c forge.Comment) bool {
    if c.System {
        return true // label changes and state transitions, not answers
    }

    handle(c)

    return true
})

Skip System comments unless you want them: they are the forge's own notes about label changes and assignments, and relaying them to an outside audience republishes triage mechanics as though they were replies.

No order is promised. GitHub and Gitea filter Since server-side and return oldest-first; GitLab has no such parameter and emulates it by reading newest-first and stopping early. Forcing them to agree would mean buffering everything, which is what Since exists to avoid. Sort what you collect if order matters.