From 5fee7af99c0688096b966fd5bd45394149833c98 Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Sun, 17 Aug 2025 12:03:28 +0200 Subject: [PATCH 1/7] :sparkles: Add GraphQL service integration for issue deletion --- lib/src/common/github.dart | 6 ++++++ lib/src/services/issues_service.dart | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lib/src/common/github.dart b/lib/src/common/github.dart index 6c64f390..71facc8e 100644 --- a/lib/src/common/github.dart +++ b/lib/src/common/github.dart @@ -6,6 +6,8 @@ import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart' as http_parser; import 'package:meta/meta.dart'; +import 'graphql_service.dart'; + /// The Main GitHub Client /// /// ## Example @@ -63,6 +65,7 @@ class GitHub { UrlShortenerService? _urlShortener; UsersService? _users; ChecksService? _checks; + GraphQLService? _graphql; /// The maximum number of requests that the consumer is permitted to make per /// hour. @@ -143,6 +146,9 @@ class GitHub { /// See https://developer.github.com/v3/checks/ ChecksService get checks => _checks ??= ChecksService(this); + /// Service for GraphQL related methods of the GitHub API. + GraphQLService get graphql => _graphql ??= GraphQLService(this); + /// Handles Get Requests that respond with JSON /// [path] can either be a path like '/repos' or a full url. /// [statusCode] is the expected status code. If it is null, it is ignored. diff --git a/lib/src/services/issues_service.dart b/lib/src/services/issues_service.dart index 8d3499b1..2eff58f0 100644 --- a/lib/src/services/issues_service.dart +++ b/lib/src/services/issues_service.dart @@ -566,4 +566,29 @@ class IssuesService extends Service { statusCode: 204, ); } + + /// Deletes an issue. + /// + /// This uses the GraphQL API, since issue deletion is not available in the REST API. + /// [issueId] is the GraphQL node ID of the issue, not the issue number. + /// + /// API docs: https://docs.github.com/en/graphql/reference/mutations#deleteissue + Future deleteIssue(String issueId) async { + const String mutation = r''' + mutation DeleteIssue($issueId: ID!) { + deleteIssue(input: {issueId: $issueId}) { + clientMutationId + } + } + '''; + + final result = await github.graphql.mutate( + mutation, + variables: {'issueId': issueId}, + ); + + if (result.hasException) { + throw result.exception!; + } + } } From c7149aae65f177da1904a78395a6de06df522076 Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Sun, 17 Aug 2025 12:04:49 +0200 Subject: [PATCH 2/7] :art: Format code --- lib/src/services/issues_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/services/issues_service.dart b/lib/src/services/issues_service.dart index 2eff58f0..02d81133 100644 --- a/lib/src/services/issues_service.dart +++ b/lib/src/services/issues_service.dart @@ -574,7 +574,7 @@ class IssuesService extends Service { /// /// API docs: https://docs.github.com/en/graphql/reference/mutations#deleteissue Future deleteIssue(String issueId) async { - const String mutation = r''' + const mutation = r''' mutation DeleteIssue($issueId: ID!) { deleteIssue(input: {issueId: $issueId}) { clientMutationId From 0802ec37e067d2ef7af4a2028258c6ce33d8d765 Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Sun, 17 Aug 2025 12:05:12 +0200 Subject: [PATCH 3/7] :white_check_mark: Add mock implementations for GitHub and GraphQLService in issue tests --- test/unit/issues_test.dart | 100 ++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/test/unit/issues_test.dart b/test/unit/issues_test.dart index b3c60072..77ba029f 100644 --- a/test/unit/issues_test.dart +++ b/test/unit/issues_test.dart @@ -1,6 +1,8 @@ import 'dart:convert'; -import 'package:github_flutter/src/models/issues.dart'; +import 'package:github_flutter/src/common.dart'; +import 'package:github_flutter/src/common/graphql_service.dart'; +import 'package:graphql/client.dart'; import 'package:test/test.dart'; const String testIssueCommentJson = ''' @@ -50,6 +52,41 @@ const String testIssueCommentJson = ''' } '''; +// A mock implementation of GitHub that uses noSuchMethod to avoid implementing +// all methods of the GitHub class. +class MockGitHub implements GitHub { + @override + late final GraphQLService graphql; + + MockGitHub(this.graphql); + + @override + dynamic noSuchMethod(Invocation invocation) { + return super.noSuchMethod(invocation); + } +} + +// A manual mock for GraphQLService to avoid mockito issues. +class MockGraphQLService implements GraphQLService { + // Callback to be set by the test. + late Future Function(String, Map?) onMutate; + + @override + Future mutate( + String mutation, { + Map? variables, + }) { + return onMutate(mutation, variables); + } + + // Unimplemented members + @override + GitHub get github => throw UnimplementedError(); + @override + Future query(String query, {Map? variables}) => + throw UnimplementedError(); +} + void main() { group('Issue Comments', () { test('IssueComment from Json', () { @@ -61,4 +98,65 @@ void main() { expect('CaseyHillers', issueComment.user!.login); }); }); + + group('IssuesService', () { + late IssuesService issuesService; + late MockGitHub mockGitHub; + late MockGraphQLService mockGraphQLService; + + setUp(() { + mockGraphQLService = MockGraphQLService(); + mockGitHub = MockGitHub(mockGraphQLService); + issuesService = IssuesService(mockGitHub); + }); + + test('deleteIssue success', () async { + // Arrange + String? capturedMutation; + Map? capturedVariables; + + mockGraphQLService.onMutate = (mutation, variables) { + capturedMutation = mutation; + capturedVariables = variables; + return Future.value( + QueryResult( + options: QueryOptions(document: gql('')), + source: QueryResultSource.network, + data: const { + 'deleteIssue': {'clientMutationId': '1234'}, + }, + ), + ); + }; + + // Act + await issuesService.deleteIssue('issue-id-123'); + + // Assert + expect(capturedMutation, contains('mutation DeleteIssue')); + expect(capturedVariables, {'issueId': 'issue-id-123'}); + }); + + test('deleteIssue failure', () async { + // Arrange + final exception = OperationException( + graphqlErrors: [const GraphQLError(message: 'Failed to delete')], + ); + mockGraphQLService.onMutate = (mutation, variables) { + return Future.value( + QueryResult( + options: QueryOptions(document: gql('')), + source: QueryResultSource.network, + exception: exception, + ), + ); + }; + + // Act & Assert + expect( + () => issuesService.deleteIssue('issue-id-123'), + throwsA(isA()), + ); + }); + }); } From 38e37c7a5557728b6184884d6adf7665d18a70ad Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Sun, 17 Aug 2025 12:05:18 +0200 Subject: [PATCH 4/7] :sparkles: Add GraphQLService for handling GraphQL requests and queries --- lib/src/common/graphql_service.dart | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 lib/src/common/graphql_service.dart diff --git a/lib/src/common/graphql_service.dart b/lib/src/common/graphql_service.dart new file mode 100644 index 00000000..5a45a68f --- /dev/null +++ b/lib/src/common/graphql_service.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:graphql/client.dart'; + +import 'github.dart'; + +/// Service for handling GraphQL requests. +class GraphQLService { + final GitHub github; + late final GraphQLClient _client; + + GraphQLService(this.github) { + final httpLink = HttpLink('https://api.github.com/graphql'); + + final authLink = AuthLink( + getToken: () async => 'Bearer ${github.auth.token}', + ); + + final link = authLink.concat(httpLink); + + _client = GraphQLClient(cache: GraphQLCache(), link: link); + } + + /// Performs a GraphQL query. + Future query( + String query, { + Map? variables, + }) async { + final options = QueryOptions( + document: gql(query), + variables: variables ?? const {}, + ); + return _client.query(options); + } + + /// Performs a GraphQL mutation. + Future mutate( + String mutation, { + Map? variables, + }) async { + final options = MutationOptions( + document: gql(mutation), + variables: variables ?? const {}, + ); + return _client.mutate(options); + } +} From 91699ca9bf81650cd2c146ece3a6a2149086ad65 Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Sun, 17 Aug 2025 12:05:28 +0200 Subject: [PATCH 5/7] :heavy_plus_sign: Add graphql dependency to pubspec.yaml --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index 24c9bba1..27849647 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: flutter: sdk: flutter + graphql: ^5.2.1 http: ^1.3.0 http_parser: json_annotation: ^4.9.0 From 2e35c6c784755fee89ea638035475832bb24123e Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Sun, 17 Aug 2025 14:10:05 +0200 Subject: [PATCH 6/7] :sparkles: Update deleteIssue method to use RepositorySlug and issueNumber for GraphQL API --- lib/src/services/issues_service.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/services/issues_service.dart b/lib/src/services/issues_service.dart index 02d81133..946b6d4f 100644 --- a/lib/src/services/issues_service.dart +++ b/lib/src/services/issues_service.dart @@ -570,10 +570,12 @@ class IssuesService extends Service { /// Deletes an issue. /// /// This uses the GraphQL API, since issue deletion is not available in the REST API. - /// [issueId] is the GraphQL node ID of the issue, not the issue number. /// /// API docs: https://docs.github.com/en/graphql/reference/mutations#deleteissue - Future deleteIssue(String issueId) async { + Future deleteIssue(RepositorySlug slug, int issueNumber) async { + final issue = await get(slug, issueNumber); + final issueId = issue.nodeId; + const mutation = r''' mutation DeleteIssue($issueId: ID!) { deleteIssue(input: {issueId: $issueId}) { From 177319831e8106728e29f770817d2becd133fdb5 Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Sun, 17 Aug 2025 14:10:47 +0200 Subject: [PATCH 7/7] :white_check_mark: Refactor deleteIssue tests to use RepositorySlug and issueNumber --- test/unit/issues_test.dart | 137 +++++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 7 deletions(-) diff --git a/test/unit/issues_test.dart b/test/unit/issues_test.dart index 77ba029f..f468576c 100644 --- a/test/unit/issues_test.dart +++ b/test/unit/issues_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:github_flutter/src/common.dart'; import 'package:github_flutter/src/common/graphql_service.dart'; import 'package:graphql/client.dart'; +import 'package:http/http.dart' as http; import 'package:test/test.dart'; const String testIssueCommentJson = ''' @@ -52,17 +53,52 @@ const String testIssueCommentJson = ''' } '''; +typedef GetJSONCallback = + Future Function( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }); + // A mock implementation of GitHub that uses noSuchMethod to avoid implementing // all methods of the GitHub class. class MockGitHub implements GitHub { @override late final GraphQLService graphql; + late GetJSONCallback onGetJSON; + MockGitHub(this.graphql); + @override + Future getJSON( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }) { + return onGetJSON( + path, + statusCode: statusCode, + fail: fail, + headers: headers, + params: params, + convert: convert, + preview: preview, + ); + } + @override dynamic noSuchMethod(Invocation invocation) { - return super.noSuchMethod(invocation); + // This is needed to avoid implementing all methods of the GitHub class. + // We only care about getJSON and graphql. } } @@ -114,13 +150,44 @@ void main() { // Arrange String? capturedMutation; Map? capturedVariables; + final slug = RepositorySlug('owner', 'repo'); + const issueNumber = 1; + const issueNodeId = 'issue-node-id-456'; + + mockGitHub.onGetJSON = ( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }) async { + if (path == '/repos/owner/repo/issues/1') { + final issueJson = { + 'id': 1, + 'node_id': issueNodeId, + 'number': issueNumber, + 'state': 'open', + 'title': 'Test Issue', + 'url': 'https://api.github.com/repos/owner/repo/issues/1', + 'html_url': 'https://github.com/owner/repo/issues/1', + 'body': 'Test Body', + }; + final issue = convert!(issueJson as S); + return issue; + } + throw Exception('Unexpected path: $path'); + }; mockGraphQLService.onMutate = (mutation, variables) { capturedMutation = mutation; capturedVariables = variables; return Future.value( QueryResult( - options: QueryOptions(document: gql('')), + options: QueryOptions( + document: gql(''), + ), // ignore: deprecated_member_use source: QueryResultSource.network, data: const { 'deleteIssue': {'clientMutationId': '1234'}, @@ -130,22 +197,78 @@ void main() { }; // Act - await issuesService.deleteIssue('issue-id-123'); + await issuesService.deleteIssue(slug, issueNumber); // Assert expect(capturedMutation, contains('mutation DeleteIssue')); - expect(capturedVariables, {'issueId': 'issue-id-123'}); + expect(capturedVariables, {'issueId': issueNodeId}); }); - test('deleteIssue failure', () async { + test('deleteIssue failure on get', () async { // Arrange + final slug = RepositorySlug('owner', 'repo'); + const issueNumber = 1; + + mockGitHub.onGetJSON = ( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }) async { + throw Exception('Failed to get issue'); + }; + + // Act & Assert + expect( + () => issuesService.deleteIssue(slug, issueNumber), + throwsA(isA()), + ); + }); + + test('deleteIssue failure on mutate', () async { + // Arrange + final slug = RepositorySlug('owner', 'repo'); + const issueNumber = 1; + const issueNodeId = 'issue-node-id-456'; + + mockGitHub.onGetJSON = ( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }) async { + if (path == '/repos/owner/repo/issues/1') { + final issueJson = { + 'id': 1, + 'node_id': issueNodeId, + 'number': issueNumber, + 'state': 'open', + 'title': 'Test Issue', + 'url': 'https://api.github.com/repos/owner/repo/issues/1', + 'html_url': 'https://github.com/owner/repo/issues/1', + 'body': 'Test Body', + }; + final issue = convert!(issueJson as S); + return issue; + } + throw Exception('Unexpected path: $path'); + }; + final exception = OperationException( graphqlErrors: [const GraphQLError(message: 'Failed to delete')], ); mockGraphQLService.onMutate = (mutation, variables) { return Future.value( QueryResult( - options: QueryOptions(document: gql('')), + options: QueryOptions( + document: gql(''), + ), // ignore: deprecated_member_use source: QueryResultSource.network, exception: exception, ), @@ -154,7 +277,7 @@ void main() { // Act & Assert expect( - () => issuesService.deleteIssue('issue-id-123'), + () => issuesService.deleteIssue(slug, issueNumber), throwsA(isA()), ); });