import 'package:github/github.dart'; class GitHubIssueSummary { GitHubIssueSummary({ required this.trackerProject, required this.repoSlug, required this.number, required this.title, required this.body, required this.state, required this.url, required this.updatedAt, required this.userLogin, required this.labels, }); final String trackerProject; final RepositorySlug? repoSlug; final int number; final String title; final String body; final String state; final String url; final DateTime updatedAt; final String userLogin; final List labels; factory GitHubIssueSummary.fromRepositoryJson( RepositorySlug repo, Map json, ) { final labelsNode = json['labels'] as List? ?? const []; return GitHubIssueSummary( trackerProject: repo.fullName, repoSlug: repo, number: readIntField(json, 'number', fallbackKey: 'index'), title: json['title'] as String? ?? '', body: json['body'] as String? ?? '', state: json['state'] as String? ?? 'open', url: readUrlField(json), updatedAt: DateTime.parse(readStringField(json, 'updated_at')), userLogin: readLoginField(json['user']) ?? readLoginField(json['poster']) ?? '', labels: labelsNode .map( (label) => readStringField(label as Map, 'name'), ) .toList(growable: false), ); } factory GitHubIssueSummary.fromOpenProjectJson( String trackerProject, Map json, Uri host, ) { final embedded = json['_embedded'] as Map?; final description = embedded?['description'] as Map? ?? json['description'] as Map?; return GitHubIssueSummary( trackerProject: trackerProject, repoSlug: null, number: readIntField(json, 'id'), title: json['subject'] as String? ?? '', body: description?['raw'] as String? ?? '', state: 'open', url: resolveTrackerUrl(readUrlField(json), host), updatedAt: DateTime.parse(readStringField(json, 'updatedAt')), userLogin: readLinkTitleField( (json['_links'] as Map?)?['author'], ) ?? '', labels: const [], ); } factory GitHubIssueSummary.fromGitLabJson( String trackerProject, Map json, ) { final labelsNode = json['labels'] as List? ?? const []; return GitHubIssueSummary( trackerProject: trackerProject, repoSlug: null, number: readIntField(json, 'iid'), title: json['title'] as String? ?? '', body: json['description'] as String? ?? '', state: json['state'] as String? ?? 'opened', url: readUrlField(json), updatedAt: DateTime.parse(readStringField(json, 'updated_at')), userLogin: readLoginField(json['author']) ?? '', labels: labelsNode .map((label) => label.toString()) .toList(growable: false), ); } factory GitHubIssueSummary.fromSearchJson(Map json) { final repositoryUrl = json['repository_url'] as String?; if (repositoryUrl == null || repositoryUrl.isEmpty) { throw const FormatException( 'Missing repository_url in GitHub search result item.', ); } return GitHubIssueSummary.fromRepositoryJson( parseRepositorySlugFromRepositoryUrl(repositoryUrl), json, ); } factory GitHubIssueSummary.fromJson(Map json) { final trackerProject = json['repo'] as String? ?? ''; RepositorySlug? repoSlug; try { repoSlug = parseRepositorySlug(trackerProject); } on FormatException { repoSlug = null; } return GitHubIssueSummary( trackerProject: trackerProject, repoSlug: repoSlug, number: readIntField(json, 'number'), title: json['title'] as String? ?? '', body: json['body'] as String? ?? '', state: json['state'] as String? ?? 'open', url: readStringField(json, 'url'), updatedAt: DateTime.parse(readStringField(json, 'updated_at')), userLogin: json['user_login'] as String? ?? '', labels: (json['labels'] as List? ?? const []) .map((label) => label.toString()) .toList(growable: false), ); } RepositorySlug get requiredRepoSlug { final slug = repoSlug; if (slug == null) { throw StateError( 'Tracker project "$trackerProject" does not use a repository slug.', ); } return slug; } Map toJson() => { 'repo': trackerProject, 'number': number, 'title': title, 'body': body, 'state': state, 'url': url, 'updated_at': updatedAt.toUtc().toIso8601String(), 'user_login': userLogin, 'labels': labels, }; } class GitHubIssueComment { GitHubIssueComment({ required this.id, required this.body, required this.userLogin, required this.createdAt, required this.updatedAt, required this.url, }); final int id; final String body; final String userLogin; final DateTime createdAt; final DateTime updatedAt; final String url; factory GitHubIssueComment.fromJson(Map json) { return GitHubIssueComment( id: readIntField(json, 'id'), body: json['body'] as String? ?? '', userLogin: readLoginField(json['user']) ?? readLoginField(json['poster']) ?? '', createdAt: DateTime.parse(readStringField(json, 'created_at')), updatedAt: DateTime.parse(readStringField(json, 'updated_at')), url: readUrlField(json), ); } factory GitHubIssueComment.fromOpenProjectJson( Map json, Uri host, ) { return GitHubIssueComment( id: readIntField(json, 'id'), body: readOpenProjectCommentBody(json) ?? '', userLogin: readLinkTitleField( (json['_links'] as Map?)?['user'], ) ?? '', createdAt: DateTime.parse(readStringField(json, 'createdAt')), updatedAt: DateTime.parse(readStringField(json, 'updatedAt')), url: resolveTrackerUrl(readUrlField(json), host), ); } factory GitHubIssueComment.fromGitLabJson( Map json, String issueUrl, ) { return GitHubIssueComment( id: readIntField(json, 'id'), body: json['body'] as String? ?? '', userLogin: readLoginField(json['author']) ?? '', createdAt: DateTime.parse(readStringField(json, 'created_at')), updatedAt: DateTime.parse(readStringField(json, 'updated_at')), url: buildGitLabNoteUrl(issueUrl: issueUrl, json: json), ); } static String buildGitLabNoteUrl({ required String issueUrl, required Map json, }) { final noteUrl = readOptionalStringField(json, 'web_url'); if (noteUrl != null && noteUrl.isNotEmpty) { return noteUrl; } final resolvedIssueUrl = readOptionalStringField(json, 'noteable_url') ?? readOptionalStringField(json, 'issue_web_url') ?? issueUrl; if (resolvedIssueUrl.isEmpty) { return resolvedIssueUrl; } return '$resolvedIssueUrl#note_${readIntField(json, "id")}'; } Map toJson() => { 'id': id, 'body': body, 'user_login': userLogin, 'created_at': createdAt.toUtc().toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(), 'url': url, }; } class IssueThread { IssueThread({required this.issue, required this.comments}); final GitHubIssueSummary issue; final List comments; bool get isOpen => issue.state == 'open' || issue.state == 'opened'; GitHubIssueComment? get latestComment => comments.isEmpty ? null : comments.last; Map toJson() => { 'issue': issue.toJson(), 'comments': comments.map((comment) => comment.toJson()).toList(), }; } class PostedComment { PostedComment({required this.id, required this.url, required this.body}); final int id; final String url; final String body; } int readIntField(Map json, String key, {String? fallbackKey}) { final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]); if (value is int) { return value; } if (value is String) { return int.parse(value); } throw FormatException( 'Missing integer field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.', ); } String readStringField( Map json, String key, { String? fallbackKey, }) { final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]); if (value is String && value.isNotEmpty) { return value; } throw FormatException( 'Missing string field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.', ); } String? readOptionalStringField(Map json, String key) { final value = json[key]; if (value is String && value.isNotEmpty) { return value; } return null; } String readUrlField(Map json) { return json['html_url'] as String? ?? json['web_url'] as String? ?? json['url'] as String? ?? json['htmlUrl'] as String? ?? readLinkHrefField((json['_links'] as Map?)?['self']) ?? ''; } String? readLoginField(Object? userNode) { if (userNode is Map) { final login = userNode['login']?.toString().trim(); if (login != null && login.isNotEmpty) { return login; } final userName = userNode['user_name']?.toString().trim(); if (userName != null && userName.isNotEmpty) { return userName; } final username = userNode['username']?.toString().trim(); if (username != null && username.isNotEmpty) { return username; } } return null; } String? readLinkTitleField(Object? linkNode) { if (linkNode is Map) { final title = linkNode['title']?.toString().trim(); if (title != null && title.isNotEmpty) { return title; } } return null; } String? readLinkHrefField(Object? linkNode) { if (linkNode is Map) { final href = linkNode['href']?.toString().trim(); if (href != null && href.isNotEmpty) { return href; } } return null; } String? readOpenProjectCommentBody(Map json) { final comment = json['comment']; if (comment is Map) { final raw = comment['raw']?.toString(); if (raw != null && raw.isNotEmpty) { return raw; } } return null; } String resolveTrackerUrl(String value, Uri host) { if (value.isEmpty) { return value; } final uri = Uri.parse(value); if (uri.hasScheme) { return value; } return host.resolveUri(uri).toString(); } RepositorySlug parseRepositorySlug(String value) { final parts = value.split('/'); if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) { throw FormatException( 'Invalid repository slug "$value". Expected owner/repo.', ); } return RepositorySlug(parts[0], parts[1]); } RepositorySlug parseRepositorySlugFromRepositoryUrl(String value) { final uri = Uri.parse(value); final segments = uri.pathSegments; final reposIndex = segments.indexOf('repos'); if (reposIndex == -1 || reposIndex + 2 >= segments.length) { throw FormatException( 'Invalid repository_url in GitHub search result item: "$value"', ); } return RepositorySlug(segments[reposIndex + 1], segments[reposIndex + 2]); }