gitea.watch
watch
Change detection over issue snapshots, for watching repositories and boards.
A watch run fetches the issues of the scopes it was asked about, reduces each one to a snapshot of the few fields worth reacting to, and compares those snapshots against the ones a previous run left in a local cache. What comes back is the list of changes since that run, which is empty when nothing moved - the property that lets the command be run from cron without producing output, and so without producing work, on a quiet tick.
gitea.watch.changes holds the comparison and the snapshot it compares;
gitea.watch.state holds the cache the snapshots are kept in.
Functions:
gitea.watch.comment_hash
Hash a comment's identity, stably across re-fetches.
The digest is taken over the comment's ID, author, body and timestamps, serialized as JSON so that no value can be confused with the boundary between two of them - a body ending in the separator would otherwise hash as a different comment's body beginning with it.
Including the timestamps means an edited comment hashes differently from the comment it replaced, so an edit is reported as a change rather than passing for the comment already recorded.
The author is taken by ID and never by login, because a login is renameable and the digest has to survive a rename: hashing the login would turn every comment a renamed user ever wrote into a removal and an addition, on every issue being watched, although nothing about any of them changed. A comment's author cannot change, so the ID is only there to tell two comments apart when the payload carries no comment ID of its own - and a payload carrying no user ID contributes no author at all rather than falling back to the login, which would put the rename back.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
comment
|
dict[str, Any]
|
The comment data returned by the API. A payload missing any of these fields hashes as if it carried them empty, rather than raising. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The first |
Source code in src/gitea/watch/changes.py
gitea.watch.detect_changes
detect_changes(
current: dict[str, dict[str, Any]],
previous: dict[str, dict[str, Any]] | None,
) -> list[dict[str, Any]]
Compare the snapshots of one scope against the ones recorded for it.
An issue that changed in more than one way contributes one record per way, as a digest reads better naming each change than one line naming three.
An issue the recorded scope has not seen is one of those: it contributes the
new record naming it, and then the same records everything else
contributes, taken against the empty snapshot it is being compared to - so
the assignees, labels and comments it already carries are reported as added.
Reporting only new would make them baseline, which loses a comment written
between an issue being opened and the run that first saw it, and loses it
permanently: the run that swallowed it records it and the next one has
nothing left to compare against. It matters most to a consumer that reacts
to a kind rather than to an issue - one acting on comments and not on
new would never learn the comment was there.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
current
|
dict[str, dict[str, Any]]
|
The snapshot of each issue in the scope now, keyed by
|
required |
previous
|
dict[str, dict[str, Any]] | None
|
The snapshots the last run recorded for the scope, or None when the scope has never been recorded. None baselines the scope: no change is reported, whatever is in it. |
required |
Returns:
| Type | Description |
|---|---|
list[dict[str, Any]]
|
The changes since the recorded snapshots, ordered by issue and then by |
list[dict[str, Any]]
|
what changed. Empty when nothing changed, which is the whole point of |
list[dict[str, Any]]
|
the command. |
Source code in src/gitea/watch/changes.py
gitea.watch.format_change
Render one change as the line the human digest prints for it.
The issue is named the way it is written in a browser's address bar and in
prose - owner/repo#15 - so a line can be read, pasted and grepped without
consulting the scope it came from.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
change
|
dict[str, Any]
|
The change record. |
required |
Returns:
| Type | Description |
|---|---|
str
|
One line naming the issue, what changed and what it changed to. |
Source code in src/gitea/watch/changes.py
gitea.watch.issue_key
Build the key an issue is recorded under.
The global ID is used rather than the number shown in the web UI, because a project scope holds issues from several repositories and their numbers collide.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
issue
|
dict[str, Any]
|
The issue data returned by the API. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The key, or None when the payload carries no usable global ID. |
Source code in src/gitea/watch/changes.py
gitea.watch.issue_snapshot
issue_snapshot(
issue: dict[str, Any],
comments: list[dict[str, Any]],
repository: str | None = None,
) -> dict[str, Any]
Reduce an issue and its comments to what a watch compares and reports.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
issue
|
dict[str, Any]
|
The issue data returned by the API. |
required |
comments
|
list[dict[str, Any]]
|
The issue's comments, as returned by the API. Pass an empty list for an issue whose comments were not fetched; its comment hashes are then empty and no comment change is ever reported for it. |
required |
repository
|
str | None
|
Full name of the repository holding the issue, used to name it in the report, or None when it could not be determined. |
None
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The snapshot of the issue. |
Source code in src/gitea/watch/changes.py
gitea.watch.usable_identifier
Read a value that has to be a whole number to identify anything.
Every identifier a watch reads - an issue's global ID, its number, a column's ID - is read through here, so that a payload with a nonsense one in it is refused the same way whichever field it was in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
Any
|
The value the payload carries for the identifier. |
required |
Returns:
| Type | Description |
|---|---|
int | None
|
The identifier, or None when the value is not one. |
int | None
|
it is an |
int | None
|
whose ID came back as a boolean under the same entry. |
Source code in src/gitea/watch/changes.py
gitea.watch.cache_lock
Hold the lock guarding one cache for the duration of the block.
The lock is advisory and taken through the operating system, so a run that is killed while holding it releases it rather than leaving a file behind that every later run waits on. The lock file itself is never removed, for the same reason a lock is not the file's existence: unlinking it while another run holds it open would let the next run lock a different file.
Failing to lock is not failing to run. A platform with neither locking call, a filesystem that refuses the lock, and a wait that runs out are all logged and yield anyway, leaving the caller in the read/modify/write race it was in before there was a lock rather than leaving it unable to watch anything.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache to guard. |
required |
Yields:
| Type | Description |
|---|---|
bool
|
Whether the lock is actually held for the duration of the block. |
Source code in src/gitea/watch/state.py
gitea.watch.default_state_path
Build the path of the cache used when none is named.
Returns:
| Type | Description |
|---|---|
Path
|
The cache file in the user's cache directory for this application. |
Source code in src/gitea/watch/state.py
gitea.watch.load_state
Read the cache, treating anything unreadable as an absent one.
A missing file is the ordinary first run and is not reported. A file that exists but cannot be read as a cache document is reported as a warning, because it means the scopes in it are about to be baselined again and the changes since the last good write will never be reported.
A file whose bytes are not UTF-8 at all is one of those, and is caught here
rather than left to the caller: decoding raises UnicodeDecodeError, which
is a ValueError and not an OSError, so catching only the latter would
let a cache truncated mid-character - or a wholly unrelated binary file
named as one - end the run instead of re-baselining it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The cache document, or an empty one when there is nothing to read. |
Source code in src/gitea/watch/state.py
gitea.watch.record_scope
Replace what the cache records for one scope.
Only that scope's entry is touched, so the scopes a run did not watch - and any key of the document this version does not know about - survive the write.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
dict[str, Any]
|
The cache document, modified in place. |
required |
scope
|
str
|
Key of the scope. |
required |
snapshots
|
dict[str, dict[str, Any]]
|
The snapshot of each issue currently in the scope. |
required |
Source code in src/gitea/watch/state.py
gitea.watch.resolve_state_path
Choose the cache a run reads and writes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state_file
|
str | Path | None
|
The path named on the command line or by |
None
|
Returns:
| Type | Description |
|---|---|
Path
|
The path of the cache. |
Source code in src/gitea/watch/state.py
gitea.watch.save_scopes
Record the scopes a run watched, leaving every other scope as it is.
A run is authoritative only for the scopes it was asked to watch, so the document is re-read here - immediately before it is written, rather than at the start of the run - and only those scopes are replaced in it. Writing the document the run started from would put back whatever it held then, erasing the scopes a concurrent run recorded while this one was fetching.
Re-reading is not enough on its own: two runs reaching this point together
would both read the same document, and the second rename would still drop
what the first recorded. The read, the change and the write are therefore
one critical section held under cache_lock, so the second run reads what
the first wrote. A cache that cannot be locked is still written, and is back
to being racy rather than unusable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache. |
required |
scopes
|
dict[str, dict[str, dict[str, Any]]]
|
The snapshots to record, keyed by scope. |
required |
Raises:
| Type | Description |
|---|---|
OSError
|
If the cache cannot be written. It is left as it was. |
Source code in src/gitea/watch/state.py
gitea.watch.save_state
Write the cache, so that no reader ever sees a partial document.
The document is written to a temporary file in the directory the cache lives in and renamed over it, which is atomic on every platform this runs on, and is flushed to disk first so the rename cannot publish an empty file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache. |
required |
state
|
dict[str, Any]
|
The cache document to write. |
required |
Raises:
| Type | Description |
|---|---|
OSError
|
If the cache directory, the temporary file or the rename cannot be written. The cache is left as it was. |
Source code in src/gitea/watch/state.py
gitea.watch.scope_snapshots
Read the snapshots recorded for one scope.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
dict[str, Any]
|
The cache document. |
required |
scope
|
str
|
Key of the scope. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, dict[str, Any]] | None
|
The snapshot of each issue, keyed as the cache keys them, or None when |
dict[str, dict[str, Any]] | None
|
the scope has never been recorded - which is what baselines it rather |
dict[str, dict[str, Any]] | None
|
than reporting every issue in it as new. |