Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions lib/features/bug_tracker/data/bug_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ library;

import 'package:dpip/core/network/api_client.dart';

/// The forum tag that marks a thread as about THIS app. A routing marker, not
/// a category — it is filtered on, both by the server and again here, and
/// never rendered.
const String appBugTag = 'dpip';

/// Reads the reported-bug threads from the tracker host.
///
/// Absolute URL on purpose: `bamboo.exptech.dev` is a single host outside the
Expand All @@ -14,13 +19,18 @@ class BugApi {

final ApiClient _client;

static const String _base = 'https://bamboo.exptech.dev/api/dc/bug';
static const String _base = 'https://bamboo.exptech.dev/api/v1/dc/bug';

/// Every thread in the index, capped at 50 so the payload stays bounded as
/// the tracker grows. The query rides the URL, so the ETag store keys it as
/// its own resource.
///
/// `tag` asks the server for this app's threads only. It matters for the cap
/// as much as for correctness: the tracker is shared with other products, so
/// an unfiltered page of 50 spends some of its rows on threads this app then
/// throws away — measured against the live index, 3 of 50.
Future<dynamic> list() =>
_client.getAbsolute(_base, query: const {'limit': 50});
_client.getAbsolute(_base, query: const {'limit': 50, 'tag': appBugTag});

/// One thread with its replies.
Future<dynamic> thread(int id) => _client.getAbsolute('$_base/$id');
Expand Down
40 changes: 17 additions & 23 deletions lib/features/bug_tracker/data/bug_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@ import 'package:dpip/features/bug_tracker/domain/bug_thread.dart';
import 'package:dpip/features/bug_tracker/domain/bug_repository.dart';
import 'package:flutter/foundation.dart';

/// The forum tag that marks a thread as about THIS app. A routing marker, not
/// a category — it is filtered on and never rendered.
///
/// In canonical form: see [canonicalBugTag] for why the two endpoints disagree
/// about how they spell it.
const String appBugTag = 'dpip';

class BugRepositoryImpl implements BugRepository {
const BugRepositoryImpl(this._api);

Expand Down Expand Up @@ -44,22 +37,20 @@ String _normalise(String body) => body.replaceAllMapped(
(match) => match.group(1) ?? match.input,
);

/// The two tracker endpoints spell the same tag two different ways.
/// A tag as the app matches it: the forum's slug, lower-cased.
///
/// The index sends the forum's slugs — `["dpip", "bug"]`. The detail endpoint
/// still reflects Discord's raw forum labels, which are bilingual — `["臭蟲
/// bug", "DPIP", "已解決 fixed"]`. Left alone, one thread carries `bug` in the
/// list and `臭蟲` on its own page, and the routing marker matches `dpip` in
/// one place and `DPIP` in the other — which drops every thread from the
/// index, because nothing equals `DPIP` once the server started sending slugs.
/// Both endpoints now send slugs — `["bug", "dpip", "fixed"]` — verified
/// across the index and ten detail replies covering every tag in the
/// vocabulary. They did not always agree: the detail endpoint used to reflect
/// Discord's raw bilingual labels (`臭蟲 bug`, `DPIP`, `已解決 fixed`), so this
/// took a label's English tail to reconcile the two. That split is gone with
/// the behaviour it compensated for; a tag with a space in it is now a tag
/// with a space in it.
///
/// The slug is the canonical form, so a bilingual label yields its English
/// tail. `臭蟲 bug` → `bug`, `DPIP` → `dpip`, `bug` → `bug`.
String canonicalBugTag(String tag) {
final trimmed = tag.trim();
final space = trimmed.lastIndexOf(' ');
return (space < 0 ? trimmed : trimmed.substring(space + 1)).toLowerCase();
}
/// The lower-casing stays. It is what the routing marker actually needed —
/// `DPIP` had to equal `dpip` or every thread fell out of the index — and it
/// is one comparison against a regression whose other failure mode is silent.
String canonicalBugTag(String tag) => tag.trim().toLowerCase();

List<String> _canonicalTags(Object? raw) {
if (raw is! List) return const [];
Expand Down Expand Up @@ -145,8 +136,11 @@ List<BugThread> parseBugThreads(Object? body) {
];
// `dpip` is the forum's routing marker: threads without it are not about
// this app (other products share the tracker), so they never reach the
// index. Locked threads are staff-side conversations — same. Matched in
// canonical form, because the marker arrives spelt both ways.
// index. `BugApi.list` already asks the server for that tag, so this is the
// backstop rather than the mechanism — a server that stops honouring the
// query would otherwise put another product's threads in this list with no
// other symptom. Locked threads are staff-side conversations, which the
// query cannot express at all.
threads.removeWhere(
(thread) =>
thread.locked || !thread.tags.map(canonicalBugTag).contains(appBugTag),
Expand Down
18 changes: 10 additions & 8 deletions test/features/bug_tracker/bug_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
/// live tracker's updated contract (2026-08-27): a `users` directory keyed by
/// Discord snowflake plus `threads`/`msg` entries that reference it by id.
///
/// The two endpoints spell tags differently — the index sends the forum's
/// slugs, the detail endpoint still reflects Discord's bilingual labels — so
/// the fixtures deliberately use both forms and both must canonicalise the
/// same way.
/// Both endpoints send the forum's slugs, lower-case (`["bug", "dpip"]`). The
/// detail endpoint used to reflect Discord's bilingual labels instead; the
/// fixtures still put a upper-case marker through the parser, because that is
/// the spelling whose failure is silent — a routing tag that stops matching
/// empties the whole list rather than mislabelling one badge.
library;

import 'package:dpip/features/bug_tracker/data/bug_repository_impl.dart';
Expand Down Expand Up @@ -50,14 +51,14 @@ void main() {
);
});

test('the index accepts the detail endpoint\'s bilingual labels too', () {
test('a tag is matched by its slug, whatever case it arrives in', () {
final threads = parseBugThreads({
'users': {_chenId: _user(_chenId, '陳')},
'threads': [
{
'threads_id': 1,
'title': 't',
'tags': ['DPIP', '臭蟲 bug', '已解決 fixed'],
'tags': ['DPIP', 'Bug', 'fixed'],
'body': 'b',
'author': _chenId,
'created_at': 1787511150,
Expand All @@ -66,8 +67,9 @@ void main() {
],
});

// Canonical form is the slug, so the English tail wins and the routing
// marker matches whichever way the server spelt it.
// The marker matched despite its case (the thread survived at all), and
// the categories come through lower-cased, which is the form the badge
// table is keyed by.
expect(threads.single.tags, ['bug', 'fixed']);
});

Expand Down
Loading