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, viaforge.InertMentions:@someonebecomes`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¶
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:
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:
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.
Related¶
- Optional capabilities: why type assertion
- Discover repositories: the read-only capabilities
- Providers: which backends support what
- Author a provider: implementing these